From 1d0beeeef93495e26e7581058aaeec349f0f5259 Mon Sep 17 00:00:00 2001 From: Onero-testdev Date: Tue, 30 Jun 2026 16:01:34 +0800 Subject: [PATCH] Add SwitchBot Standing Fan select platform (#173580) Co-authored-by: Claude Opus 4.8 --- .../components/switchbot/__init__.py | 7 +- homeassistant/components/switchbot/number.py | 72 ++++++++++++++++++- homeassistant/components/switchbot/select.py | 46 +++++++++++- .../components/switchbot/strings.json | 14 ++++ tests/components/switchbot/test_number.py | 51 ++++++++++++- tests/components/switchbot/test_select.py | 45 +++++++++++- 6 files changed, 230 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index e868b80484bb..b11ce5d65637 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -96,7 +96,12 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.STANDING_FAN.value: [Platform.SWITCH, Platform.SENSOR], + SupportedModels.STANDING_FAN.value: [ + Platform.SELECT, + Platform.NUMBER, + Platform.SWITCH, + Platform.SENSOR, + ], SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], diff --git a/homeassistant/components/switchbot/number.py b/homeassistant/components/switchbot/number.py index 094e66fe579f..0bba7f160edd 100644 --- a/homeassistant/components/switchbot/number.py +++ b/homeassistant/components/switchbot/number.py @@ -1,5 +1,7 @@ """Number platform for SwitchBot devices.""" +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import timedelta import logging from typing import override @@ -8,7 +10,11 @@ import switchbot from switchbot import SwitchbotOperationError from switchbot.devices.meter_pro import MAX_TIME_OFFSET -from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -35,6 +41,11 @@ async def async_setup_entry( async_add_entities( [SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True ) + elif isinstance(coordinator.device, switchbot.SwitchbotStandingFan): + async_add_entities( + SwitchBotStandingFanOscillationAngleNumber(coordinator, desc) + for desc in OSCILLATION_NUMBER_DESCRIPTIONS + ) class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity): @@ -78,3 +89,62 @@ class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity) ) return self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE) + + +@dataclass(frozen=True, kw_only=True) +class SwitchBotOscillationAngleNumberEntityDescription(NumberEntityDescription): + """Describes a Standing Fan oscillation angle number entity.""" + + setter: Callable[[switchbot.SwitchbotStandingFan, int], Awaitable[None]] + + +OSCILLATION_NUMBER_DESCRIPTIONS: tuple[ + SwitchBotOscillationAngleNumberEntityDescription, ... +] = ( + SwitchBotOscillationAngleNumberEntityDescription( + key="horizontal_oscillation_angle", + translation_key="horizontal_oscillation_angle", + native_min_value=30, + native_max_value=90, + native_step=30, + setter=lambda device, angle: device.set_horizontal_oscillation_angle(angle), + ), + SwitchBotOscillationAngleNumberEntityDescription( + key="vertical_oscillation_angle", + translation_key="vertical_oscillation_angle", + native_min_value=30, + native_max_value=90, + native_step=30, + setter=lambda device, angle: device.set_vertical_oscillation_angle(angle), + ), +) + + +class SwitchBotStandingFanOscillationAngleNumber(SwitchbotEntity, NumberEntity): + """Number entity for oscillation angle on Standing Fan. + + Uses assumed_state=True because the device does not report its current + oscillation angle back to HA — state is only known after the user sets it. + """ + + entity_description: SwitchBotOscillationAngleNumberEntityDescription + _device: switchbot.SwitchbotStandingFan + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + description: SwitchBotOscillationAngleNumberEntityDescription, + ) -> None: + """Initialize the oscillation angle number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.base_unique_id}_{description.key}" + + @exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set oscillation angle.""" + await self.entity_description.setter(self._device, int(value)) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/select.py b/homeassistant/components/switchbot/select.py index d8fcec57c538..3c3945a243e3 100644 --- a/homeassistant/components/switchbot/select.py +++ b/homeassistant/components/switchbot/select.py @@ -5,7 +5,7 @@ import logging from typing import override import switchbot -from switchbot import SwitchbotOperationError +from switchbot import NightLightState, SwitchbotOperationError from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory @@ -34,6 +34,8 @@ async def async_setup_entry( if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): async_add_entities([SwitchBotMeterProCO2TimeFormatSelect(coordinator)], True) + elif isinstance(coordinator.device, switchbot.SwitchbotStandingFan): + async_add_entities([SwitchBotStandingFanNightLightSelect(coordinator)]) class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity): @@ -74,3 +76,45 @@ class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity): self._attr_current_option = ( TIME_FORMAT_12H if device_time["12h_mode"] else TIME_FORMAT_24H ) + + +NIGHT_LIGHT_OFF = "off" +NIGHT_LIGHT_LEVEL_1 = "level_1" +NIGHT_LIGHT_LEVEL_2 = "level_2" +NIGHT_LIGHT_OPTIONS = [NIGHT_LIGHT_OFF, NIGHT_LIGHT_LEVEL_1, NIGHT_LIGHT_LEVEL_2] +NIGHT_LIGHT_TO_STATE: dict[str, NightLightState] = { + NIGHT_LIGHT_OFF: NightLightState.OFF, + NIGHT_LIGHT_LEVEL_1: NightLightState.LEVEL_1, + NIGHT_LIGHT_LEVEL_2: NightLightState.LEVEL_2, +} +NIGHT_LIGHT_FROM_STATE: dict[int, str] = { + state.value: option for option, state in NIGHT_LIGHT_TO_STATE.items() +} + + +class SwitchBotStandingFanNightLightSelect(SwitchbotEntity, SelectEntity): + """Select entity for night light on Standing Fan.""" + + _device: switchbot.SwitchbotStandingFan + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "night_light" + _attr_options = NIGHT_LIGHT_OPTIONS + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_night_light" + + @property + def current_option(self) -> str | None: + """Return current night light state.""" + state = self._device.get_night_light_state() + if state is None: + return None + return NIGHT_LIGHT_FROM_STATE.get(state) + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Set night light state.""" + await self._device.set_night_light(NIGHT_LIGHT_TO_STATE[option]) + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 3981c7e6e8d5..a30bb0d36a59 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -287,9 +287,23 @@ "number": { "display_time_offset": { "name": "Display time offset" + }, + "horizontal_oscillation_angle": { + "name": "Horizontal oscillation angle" + }, + "vertical_oscillation_angle": { + "name": "Vertical oscillation angle" } }, "select": { + "night_light": { + "name": "Night light", + "state": { + "level_1": "Level 1", + "level_2": "Level 2", + "off": "[%key:common::state::off%]" + } + }, "time_format": { "name": "Time format", "state": { diff --git a/tests/components/switchbot/test_number.py b/tests/components/switchbot/test_number.py index c0a15208f889..c9d2729ec645 100644 --- a/tests/components/switchbot/test_number.py +++ b/tests/components/switchbot/test_number.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -from . import DOMAIN, WOMETERTHPC_SERVICE_INFO +from . import DOMAIN, STANDING_FAN_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -169,3 +169,52 @@ async def test_set_display_time_offset_out_of_range( ) mock_set_time_offset.assert_not_awaited() + + +@pytest.mark.parametrize( + ("entity_id", "set_method"), + [ + ( + "number.test_name_horizontal_oscillation_angle", + "set_horizontal_oscillation_angle", + ), + ( + "number.test_name_vertical_oscillation_angle", + "set_vertical_oscillation_angle", + ), + ], +) +async def test_standing_fan_oscillation_angle_number( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + entity_id: str, + set_method: str, +) -> None: + """Test horizontal/vertical oscillation angle number entities.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="standing_fan") + entry.add_to_hass(hass) + + mocked_set = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.number.switchbot.SwitchbotStandingFan", + get_basic_info=AsyncMock(return_value=None), + **{set_method: mocked_set}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60}, + blocking=True, + ) + + mocked_set.assert_awaited_once_with(60) + + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 60 diff --git a/tests/components/switchbot/test_select.py b/tests/components/switchbot/test_select.py index 97b7c3854710..f2dbfb1dfb1b 100644 --- a/tests/components/switchbot/test_select.py +++ b/tests/components/switchbot/test_select.py @@ -4,6 +4,7 @@ from collections.abc import Callable from unittest.mock import AsyncMock, patch import pytest +from switchbot import NightLightState from homeassistant.components.select import ( ATTR_OPTION, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import DOMAIN, WOMETERTHPC_SERVICE_INFO +from . import DOMAIN, STANDING_FAN_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -123,3 +124,45 @@ async def test_set_time_format( state = hass.states.get("select.test_name_time_format") assert state is not None assert state.state == expected_state + + +@pytest.mark.parametrize( + ("device_state", "option", "expected_state"), + [ + (NightLightState.OFF.value, "level_1", NightLightState.LEVEL_1), + (NightLightState.LEVEL_1.value, "level_2", NightLightState.LEVEL_2), + (NightLightState.LEVEL_2.value, "off", NightLightState.OFF), + ], +) +async def test_standing_fan_night_light_select( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + device_state: int, + option: str, + expected_state: NightLightState, +) -> None: + """Test night light select translates options to device commands.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="standing_fan") + entry.add_to_hass(hass) + + mocked_set = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.select.switchbot.SwitchbotStandingFan", + get_basic_info=AsyncMock(return_value=None), + get_night_light_state=lambda self: device_state, + set_night_light=mocked_set, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_name_night_light", ATTR_OPTION: option}, + blocking=True, + ) + + mocked_set.assert_awaited_once_with(expected_state)