1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-06 22:36:33 +01:00

Add SwitchBot Standing Fan select platform (#173580)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Onero-testdev
2026-06-30 16:01:34 +08:00
committed by GitHub
parent f0850b67f2
commit 1d0beeeef9
6 changed files with 230 additions and 5 deletions
@@ -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],
+71 -1
View File
@@ -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()
+45 -1
View File
@@ -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()
@@ -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": {
+50 -1
View File
@@ -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
+44 -1
View File
@@ -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)