diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index cd8a8b08f1d33..84af2e2761d34 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -102,6 +102,10 @@ SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR], SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR], + SupportedModels.SMART_THERMOSTAT_RADIATOR.value: [ + Platform.CLIMATE, + Platform.SENSOR, + ], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -136,6 +140,7 @@ SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM, SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener, + SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator, } diff --git a/homeassistant/components/switchbot/climate.py b/homeassistant/components/switchbot/climate.py new file mode 100644 index 0000000000000..79b05388d2264 --- /dev/null +++ b/homeassistant/components/switchbot/climate.py @@ -0,0 +1,140 @@ +"""Support for Switchbot Climate devices.""" + +from __future__ import annotations + +import logging +from typing import Any + +import switchbot +from switchbot import ( + ClimateAction as SwitchBotClimateAction, + ClimateMode as SwitchBotClimateMode, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry +from .entity import SwitchbotEntity, exception_handler + +SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = { + SwitchBotClimateMode.HEAT: HVACMode.HEAT, + SwitchBotClimateMode.OFF: HVACMode.OFF, +} + +HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = { + HVACMode.HEAT: SwitchBotClimateMode.HEAT, + HVACMode.OFF: SwitchBotClimateMode.OFF, +} + +SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = { + SwitchBotClimateAction.HEATING: HVACAction.HEATING, + SwitchBotClimateAction.IDLE: HVACAction.IDLE, + SwitchBotClimateAction.OFF: HVACAction.OFF, +} + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Switchbot climate based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities([SwitchBotClimateEntity(coordinator)]) + + +class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity): + """Representation of a Switchbot Climate device.""" + + _device: switchbot.SwitchbotDevice + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_target_temperature_step = 0.5 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "climate" + _attr_name = None + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self._device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self._device.max_temperature + + @property + def preset_modes(self) -> list[str] | None: + """Return the list of available preset modes.""" + return self._device.preset_modes + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._device.preset_mode + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get( + self._device.hvac_mode, HVACMode.OFF + ) + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVAC modes.""" + return [ + SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode] + for mode in self._device.hvac_modes + ] + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get( + self._device.hvac_action, HVACAction.OFF + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._device.current_temperature + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self._device.target_temperature + + @exception_handler + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new HVAC mode.""" + return await self._device.set_hvac_mode( + HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode] + ) + + @exception_handler + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + return await self._device.set_preset_mode(preset_mode) + + @exception_handler + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + return await self._device.set_target_temperature(temperature) diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 4c29ed7d67298..3925aa4a58928 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -58,6 +58,7 @@ class SupportedModels(StrEnum): K11_PLUS_VACUUM = "k11+_vacuum" GARAGE_DOOR_OPENER = "garage_door_opener" CLIMATE_PANEL = "climate_panel" + SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -95,6 +96,7 @@ class SupportedModels(StrEnum): SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM, SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER, SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL, + SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -132,6 +134,7 @@ class SupportedModels(StrEnum): SwitchbotModel.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM, SwitchbotModel.GARAGE_DOOR_OPENER, + SwitchbotModel.SMART_THERMOSTAT_RADIATOR, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -153,6 +156,7 @@ class SupportedModels(StrEnum): SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM, SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch, + SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index db0ccd3f49c41..856b4bc1f983b 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,18 @@ { "entity": { + "climate": { + "climate": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-right", + "off": "mdi:hvac-off", + "schedule": "mdi:calendar-clock" + } + } + } + } + }, "fan": { "air_purifier": { "default": "mdi:air-purifier", diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 91b3ba7b85f00..06b8732f9ccd3 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -100,6 +100,19 @@ "name": "Unlocked alarm" } }, + "climate": { + "climate": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "[%key:common::state::manual%]", + "off": "[%key:common::state::off%]", + "schedule": "Schedule" + } + } + } + } + }, "cover": { "cover": { "state_attributes": { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 4a9fcd772b20c..86de074202c6b 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1200,3 +1200,26 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +SMART_THERMOSTAT_RADIATOR_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Smart Thermostat Radiator", + manufacturer_data={2409: b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00 d\x00\x116@", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Smart Thermostat Radiator", + manufacturer_data={2409: b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00 d\x00\x116@"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Smart Thermostat Radiator"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_climate.py b/tests/components/switchbot/test_climate.py new file mode 100644 index 0000000000000..585c72e7bbdb4 --- /dev/null +++ b/tests/components/switchbot/test_climate.py @@ -0,0 +1,118 @@ +"""Tests for the Switchbot climate integration.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot import SwitchbotOperationError + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import SMART_THERMOSTAT_RADIATOR_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_HVAC_MODE, {"hvac_mode": HVACMode.HEAT}, "set_hvac_mode"), + (SERVICE_SET_PRESET_MODE, {"preset_mode": "manual"}, "set_preset_mode"), + (SERVICE_SET_TEMPERATURE, {"temperature": 22}, "set_target_temperature"), + ], +) +async def test_smart_thermostat_radiator_controlling( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test controlling the smart thermostat radiator with different services.""" + inject_bluetooth_service_info(hass, SMART_THERMOSTAT_RADIATOR_SERVICE_INFO) + + entry = mock_entry_encrypted_factory("smart_thermostat_radiator") + entity_id = "climate.test_name" + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + mocked_none_instance = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.climate.switchbot.SwitchbotSmartThermostatRadiator", + get_basic_info=mocked_none_instance, + update=mocked_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( + CLIMATE_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_SET_HVAC_MODE, {"hvac_mode": HVACMode.HEAT}, "set_hvac_mode"), + (SERVICE_SET_PRESET_MODE, {"preset_mode": "manual"}, "set_preset_mode"), + (SERVICE_SET_TEMPERATURE, {"temperature": 22}, "set_target_temperature"), + ], +) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +async def test_exception_handling_smart_thermostat_radiator_service( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for smart thermostat radiator service with exception.""" + inject_bluetooth_service_info(hass, SMART_THERMOSTAT_RADIATOR_SERVICE_INFO) + + entry = mock_entry_encrypted_factory("smart_thermostat_radiator") + entry.add_to_hass(hass) + entity_id = "climate.test_name" + + mocked_none_instance = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.climate.switchbot.SwitchbotSmartThermostatRadiator", + get_basic_info=mocked_none_instance, + update=mocked_none_instance, + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + )