Add switchbot circulator fan support (#142980)

* add support for circulator fan

* add fan unin tests

* optimize unit tests

* add fan mode translation and icon

* optimize fan unit test
This commit is contained in:
Retha Runolfsson 2025-05-01 01:58:33 +08:00 committed by GitHub
parent 1626b3b7c9
commit 9732b8c0dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 325 additions and 0 deletions

View File

@ -72,6 +72,7 @@ PLATFORMS_BY_TYPE = {
Platform.SENSOR,
],
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@ -87,6 +88,7 @@ CLASS_BY_DEVICE = {
SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
}

View File

@ -37,6 +37,7 @@ class SupportedModels(StrEnum):
REMOTE = "remote"
ROLLER_SHADE = "roller_shade"
HUBMINI_MATTER = "hubmini_matter"
CIRCULATOR_FAN = "circulator_fan"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -54,6 +55,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM,
SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1,
SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE,
SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {

View File

@ -0,0 +1,122 @@
"""Support for SwitchBot Fans."""
from __future__ import annotations
import logging
from typing import Any
import switchbot
from switchbot import FanMode
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot fan based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([SwitchBotFanEntity(coordinator)])
class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity):
"""Representation of a Switchbot."""
_device: switchbot.SwitchbotFan
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.OSCILLATE
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = FanMode.get_modes()
_attr_translation_key = "fan"
_attr_name = None
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the switchbot."""
super().__init__(coordinator)
self._attr_is_on = False
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
return self._device.is_on()
@property
def percentage(self) -> int | None:
"""Return the current speed as a percentage."""
return self._device.get_current_percentage()
@property
def oscillating(self) -> bool | None:
"""Return whether or not the fan is currently oscillating."""
return self._device.get_oscillating_state()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self._device.get_current_mode()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
_LOGGER.debug(
"Switchbot fan to set preset mode %s %s", preset_mode, self._address
)
self._last_run_success = bool(await self._device.set_preset_mode(preset_mode))
self.async_write_ha_state()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
_LOGGER.debug(
"Switchbot fan to set percentage %d %s", percentage, self._address
)
self._last_run_success = bool(await self._device.set_percentage(percentage))
self.async_write_ha_state()
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
_LOGGER.debug(
"Switchbot fan to set oscillating %s %s", oscillating, self._address
)
self._last_run_success = bool(await self._device.set_oscillation(oscillating))
self.async_write_ha_state()
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
_LOGGER.debug(
"Switchbot fan to set turn on %s %s %s",
percentage,
preset_mode,
self._address,
)
self._last_run_success = bool(await self._device.turn_on())
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
_LOGGER.debug("Switchbot fan to set turn off %s", self._address)
self._last_run_success = bool(await self._device.turn_off())
self.async_write_ha_state()

View File

@ -0,0 +1,18 @@
{
"entity": {
"fan": {
"fan": {
"state_attributes": {
"preset_mode": {
"state": {
"normal": "mdi:fan",
"natural": "mdi:leaf",
"sleep": "mdi:power-sleep",
"baby": "mdi:baby-face-outline"
}
}
}
}
}
}
}

View File

@ -160,6 +160,26 @@
}
}
}
},
"fan": {
"fan": {
"state_attributes": {
"last_run_success": {
"state": {
"true": "[%key:component::binary_sensor::entity_component::problem::state::off%]",
"false": "[%key:component::binary_sensor::entity_component::problem::state::on%]"
}
},
"preset_mode": {
"state": {
"normal": "Normal",
"natural": "Natural",
"sleep": "Sleep",
"baby": "Baby"
}
}
}
}
}
}
}

View File

@ -530,3 +530,28 @@ LOCK_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
CIRCULATOR_FAN_SERVICE_INFO = BluetoothServiceInfoBleak(
name="CirculatorFan",
manufacturer_data={
2409: b"\xb0\xe9\xfeXY\xa8~LR9",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="CirculatorFan",
manufacturer_data={
2409: b"\xb0\xe9\xfeXY\xa8~LR9",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "CirculatorFan"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@ -0,0 +1,91 @@
"""Test the switchbot fan."""
from collections.abc import Callable
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.fan import (
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DOMAIN as FAN_DOMAIN,
SERVICE_OSCILLATE,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
)
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from . import CIRCULATOR_FAN_SERVICE_INFO
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.parametrize(
(
"service",
"service_data",
"mock_method",
),
[
(
SERVICE_SET_PRESET_MODE,
{ATTR_PRESET_MODE: "baby"},
"set_preset_mode",
),
(
SERVICE_SET_PERCENTAGE,
{ATTR_PERCENTAGE: 27},
"set_percentage",
),
(
SERVICE_OSCILLATE,
{ATTR_OSCILLATING: True},
"set_oscillation",
),
(
SERVICE_TURN_OFF,
{},
"turn_off",
),
(
SERVICE_TURN_ON,
{},
"turn_on",
),
],
)
async def test_circulator_fan_controlling(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
) -> None:
"""Test controlling the circulator fan with different services."""
inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="circulator_fan")
entity_id = "fan.test_name"
entry.add_to_hass(hass)
mocked_instance = AsyncMock(return_value=True)
mcoked_none_instance = AsyncMock(return_value=None)
with patch.multiple(
"homeassistant.components.switchbot.fan.switchbot.SwitchbotFan",
get_basic_info=mcoked_none_instance,
**{mock_method: mocked_instance},
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
FAN_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_instance.assert_awaited_once()

View File

@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import (
CIRCULATOR_FAN_SERVICE_INFO,
HUBMINI_MATTER_SERVICE_INFO,
LEAK_SERVICE_INFO,
REMOTE_SERVICE_INFO,
@ -340,3 +341,47 @@ async def test_hubmini_matter_sensor(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_fan_sensors(hass: HomeAssistant) -> None:
"""Test setting up creates the sensors."""
await async_setup_component(hass, DOMAIN, {})
inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO)
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "circulator_fan",
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.switchbot.fan.switchbot.SwitchbotFan.update",
return_value=True,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 2
battery_sensor = hass.states.get("sensor.test_name_battery")
battery_sensor_attrs = battery_sensor.attributes
assert battery_sensor.state == "82"
assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery"
assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal")
rssi_sensor_attrs = rssi_sensor.attributes
assert rssi_sensor.state == "-60"
assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal"
assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()