diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 73b7307aa2d..8f417bc641a 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -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, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 787c1fa720b..41bbb247929 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -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 = { diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py new file mode 100644 index 00000000000..f704af309bf --- /dev/null +++ b/homeassistant/components/switchbot/fan.py @@ -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() diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json new file mode 100644 index 00000000000..a1c1682d255 --- /dev/null +++ b/homeassistant/components/switchbot/icons.json @@ -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" + } + } + } + } + } + } +} diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index c9f93cce604..f0d075eafc9 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -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" + } + } + } + } } } } diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 3d7ecc4d2c0..941d58c8e3a 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -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, +) diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py new file mode 100644 index 00000000000..815d3aceda3 --- /dev/null +++ b/tests/components/switchbot/test_fan.py @@ -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() diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 72ec3a8c727..8b1e6c83f21 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -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()