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:
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user