mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
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:
parent
1626b3b7c9
commit
9732b8c0dd
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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 = {
|
||||
|
122
homeassistant/components/switchbot/fan.py
Normal file
122
homeassistant/components/switchbot/fan.py
Normal 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()
|
18
homeassistant/components/switchbot/icons.json
Normal file
18
homeassistant/components/switchbot/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
|
91
tests/components/switchbot/test_fan.py
Normal file
91
tests/components/switchbot/test_fan.py
Normal 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()
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user