Switchbot Cloud:Add a night light control to the fan (#177514)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Samuel Xiao
2026-08-21 15:11:46 -04:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Joost Lekkerkerker
parent 4763a19826
commit 2cc0f1a96d
8 changed files with 304 additions and 5 deletions
@@ -43,6 +43,7 @@ PLATFORMS: list[Platform] = [
Platform.IMAGE,
Platform.LIGHT,
Platform.LOCK,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
@@ -64,6 +65,7 @@ class SwitchbotDevices:
switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field(
default_factory=list
)
selects: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@@ -261,6 +263,7 @@ async def make_new_device_data(
Platform.IMAGE: devices_data.images,
Platform.LIGHT: devices_data.lights,
Platform.LOCK: devices_data.locks,
Platform.SELECT: devices_data.selects,
Platform.SENSOR: devices_data.sensors,
Platform.SWITCH: devices_data.switches,
Platform.VACUUM: devices_data.vacuums,
@@ -36,6 +36,25 @@ HUMIDITY_LEVELS = {
100: 103, # High humidity mode
}
NIGHT_LIGHT_ON = "on"
NIGHT_LIGHT_OFF = "off"
NIGHT_LIGHT_BRIGHT = "bright"
NIGHT_LIGHT_SOFT = "soft"
STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP = {
NIGHT_LIGHT_ON: "on",
NIGHT_LIGHT_OFF: "off",
NIGHT_LIGHT_BRIGHT: "1",
NIGHT_LIGHT_SOFT: "2",
}
BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP = {
NIGHT_LIGHT_ON: "on",
NIGHT_LIGHT_OFF: "off",
NIGHT_LIGHT_BRIGHT: "0",
NIGHT_LIGHT_SOFT: "1",
}
@dataclass(frozen=True)
class SwitchbotCloudDeviceConfig:
@@ -128,13 +147,13 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = {
),
"Circulator Fan": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.FAN,)),
"Standing Fan": SwitchbotCloudDeviceConfig(
True, entity_config=(Platform.SENSOR, Platform.FAN)
True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT)
),
"Battery Circulator Fan": SwitchbotCloudDeviceConfig(
True, entity_config=(Platform.SENSOR, Platform.FAN)
True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT)
),
"Battery Circulator Fan 2 Pro": SwitchbotCloudDeviceConfig(
True, entity_config=(Platform.SENSOR, Platform.FAN)
True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT)
),
"Water Detector": SwitchbotCloudDeviceConfig(
True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR)
@@ -53,6 +53,11 @@
}
}
},
"select": {
"night_light_control": {
"default": "mdi:lightbulb-night"
}
},
"sensor": {
"light_level": {
"default": "mdi:brightness-7",
@@ -0,0 +1,106 @@
"""SwitchBotCloudSelect entity."""
from typing import TYPE_CHECKING, override
from switchbot_api import BatteryCirculatorFanCommands, Device, Remote, SwitchBotAPI
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator
from .const import (
BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP,
NIGHT_LIGHT_BRIGHT,
NIGHT_LIGHT_ON,
NIGHT_LIGHT_SOFT,
STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP,
)
from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: SwitchbotCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data = config.runtime_data
async_add_entities(
_async_make_entity(data.api, device, coordinator)
for device, coordinator in data.devices.selects
)
class SwitchBotCloudStandingFanNightLight(SwitchBotCloudEntity, SelectEntity):
"""SwitchBotCloud Standing Fan Night Light."""
_night_light_parameters_map: dict[str, str] = (
STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP
)
_attr_entity_category = EntityCategory.CONFIG
_attr_current_option: str | None = None
_attr_translation_key = "night_light_control"
_attr_options = list(_night_light_parameters_map)
@override
async def async_select_option(self, option: str) -> None:
"""Select the night light mode."""
if option == NIGHT_LIGHT_ON:
para = self._night_light_parameters_map.get(
NIGHT_LIGHT_BRIGHT
) or self._night_light_parameters_map.get(NIGHT_LIGHT_SOFT)
if TYPE_CHECKING:
assert para is not None
await self.send_api_command(
BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE,
parameters=para,
)
else:
await self.send_api_command(
BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE,
parameters=self._night_light_parameters_map[option],
)
self._attr_current_option = option
self.async_write_ha_state()
@override
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
night_status = self.coordinator.data.get("nightStatus")
for key, value in self._night_light_parameters_map.items():
if value == night_status:
self._attr_current_option = key
return
self._attr_current_option = None
class SwitchBotCloudBatteryCirculatorFan2ProNightLight(
SwitchBotCloudStandingFanNightLight
):
"""SwitchBotCloud Battery Circulator Fan 2 Pro Night Light."""
_night_light_parameters_map: dict[str, str] = (
BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP
)
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
) -> (
SwitchBotCloudStandingFanNightLight
| SwitchBotCloudBatteryCirculatorFan2ProNightLight
):
"""Make a SwitchBotCloudSelect entity."""
if device.device_type in ["Standing Fan", "Battery Circulator Fan"]:
return SwitchBotCloudStandingFanNightLight(api, device, coordinator)
if device.device_type == "Battery Circulator Fan 2 Pro":
return SwitchBotCloudBatteryCirculatorFan2ProNightLight(
api, device, coordinator
)
raise NotImplementedError
@@ -72,7 +72,17 @@
"name": "Display"
}
},
"select": {
"night_light_control": {
"name": "Night light",
"state": {
"bright": "Bright",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"soft": "Soft"
}
}
},
"sensor": {
"light_level": {
"name": "Light level"
@@ -65,7 +65,6 @@ BATTERY_CIRCULATOR_FAN_2_PRO_INFO = Device(
hubDeviceId="test-hub-id",
)
METER_INFO = Device(
version="V1.0",
deviceId="meter-id-1",
@@ -47,6 +47,19 @@
"fanSpeed": 3,
"battery": 22
},
{
"deviceId": "A1C3E5F7D9B0",
"deviceType": "Battery Circulator Fan 2 Pro",
"hubDeviceId": "FFFFFFFFFFF",
"mode": "direct",
"version": "V6.3",
"power": "on",
"nightStatus": "off",
"oscillation": "on",
"verticalOscillation": "on",
"fanSpeed": 3,
"battery": 22
},
{
"deviceId": "9B0D2F4A6C8E",
"deviceType": "Meter",
@@ -0,0 +1,144 @@
"""Test for the switchbot_cloud select."""
from unittest.mock import AsyncMock, patch
import pytest
from switchbot_api import Device, SwitchBotAPI
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from . import configure_integration
@pytest.mark.parametrize(
"device",
[
"Standing Fan",
"Battery Circulator Fan",
"Battery Circulator Fan 2 Pro",
],
)
async def test_night_light_coordinator_data_is_none(
hass: HomeAssistant,
mock_list_devices: AsyncMock,
mock_get_status: AsyncMock,
device: str,
) -> None:
"""Test coordinator data is none."""
mock_list_devices.return_value = [
Device(
version="V1.0",
deviceId="device-id-1",
deviceName="device-1",
deviceType=device,
hubDeviceId="test-hub-id",
),
]
mock_get_status.side_effect = [None, None]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "select.device_1_night_light"
state = hass.states.get(entity_id)
assert state.state == "unknown"
@pytest.mark.parametrize(
("device", "key_type", "expected"),
[
("Standing Fan", "on", "1"),
("Standing Fan", "off", "off"),
("Standing Fan", "bright", "1"),
("Standing Fan", "soft", "2"),
("Battery Circulator Fan 2 Pro", "bright", "0"),
("Battery Circulator Fan 2 Pro", "soft", "1"),
],
)
async def test_night_light_options(
hass: HomeAssistant,
mock_list_devices: AsyncMock,
mock_get_status: AsyncMock,
device: str,
key_type: str,
expected: str,
) -> None:
"""Test night light options."""
mock_list_devices.return_value = [
Device(
version="V1.0",
deviceId="device-id-1",
deviceName="device-1",
deviceType=device,
hubDeviceId="test-hub-id",
),
]
mock_get_status.side_effect = [
{
"deviceId": "B0E9FEDEB68C",
"deviceType": device,
"power": "on",
"fanSpeed": 3,
"mode": "direct",
"nightStatus": expected,
},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "select.device_1_night_light"
with (
patch.object(SwitchBotAPI, "send_command") as mocked_send_command,
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": key_type},
blocking=True,
)
mocked_send_command.assert_awaited_once()
assert mocked_send_command.await_args.args[3] == expected
state = hass.states.get(entity_id)
assert state.state == key_type
async def test_night_light_options_not_exist(
hass: HomeAssistant,
mock_list_devices: AsyncMock,
mock_get_status: AsyncMock,
) -> None:
"""Test night light options."""
mock_list_devices.return_value = [
Device(
version="V1.0",
deviceId="standing-fan-id-1",
deviceName="standing-fan-1",
deviceType="Standing Fan",
hubDeviceId="test-hub-id",
),
]
mock_get_status.side_effect = [
{
"deviceId": "B0E9FEDEB68C",
"deviceType": "Standing Fan",
"power": "on",
"fanSpeed": 3,
"mode": "direct",
"nightStatus": "fake_option",
},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "select.standing_fan_1_night_light"
state = hass.states.get(entity_id)
assert state.state == "unknown"