From 095f73d84f2c519b611d0225ff15ef59e1244010 Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Tue, 2 Sep 2025 02:52:43 +0900 Subject: [PATCH] Add Switchbot Cloud AC Off (#138648) Co-authored-by: Joost Lekkerkerker Co-authored-by: Erik Montnemery --- .../components/switchbot_cloud/climate.py | 53 ++++- .../switchbot_cloud/test_climate.py | 182 ++++++++++++++++++ 2 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 tests/components/switchbot_cloud/test_climate.py diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 27698420ae94..db100454d9ca 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -1,24 +1,30 @@ """Support for SwitchBot Air Conditioner remotes.""" +from logging import getLogger from typing import Any from switchbot_api import AirConditionerCommands from homeassistant.components import climate as FanState from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_TEMPERATURE, ClimateEntity, ClimateEntityFeature, HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import SwitchbotCloudData from .const import DOMAIN from .entity import SwitchBotCloudEntity +_LOGGER = getLogger(__name__) + _SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = { HVACMode.HEAT_COOL: 1, HVACMode.COOL: 2, @@ -52,7 +58,7 @@ async def async_setup_entry( ) -class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): +class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity, RestoreEntity): """Representation of a SwitchBot air conditioner. As it is an IR device, we don't know the actual state. @@ -75,6 +81,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): HVACMode.DRY, HVACMode.FAN_ONLY, HVACMode.HEAT, + HVACMode.OFF, ] _attr_hvac_mode = HVACMode.FAN_ONLY _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -83,6 +90,39 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): _attr_precision = 1 _attr_name = None + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if not ( + last_state := await self.async_get_last_state() + ) or last_state.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return + _LOGGER.debug("Last state attributes: %s", last_state.attributes) + self._attr_hvac_mode = HVACMode(last_state.state) + self._attr_fan_mode = last_state.attributes.get( + ATTR_FAN_MODE, self._attr_fan_mode + ) + self._attr_target_temperature = last_state.attributes.get( + ATTR_TEMPERATURE, self._attr_target_temperature + ) + + def _get_mode(self, hvac_mode: HVACMode | None) -> int: + new_hvac_mode = hvac_mode or self._attr_hvac_mode + _LOGGER.debug( + "Received hvac_mode: %s (Currently set as %s)", + hvac_mode, + self._attr_hvac_mode, + ) + if new_hvac_mode == HVACMode.OFF: + return _SWITCHBOT_HVAC_MODES.get( + self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE + ) + return _SWITCHBOT_HVAC_MODES.get(new_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE) + async def _do_send_command( self, hvac_mode: HVACMode | None = None, @@ -90,15 +130,16 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): temperature: float | None = None, ) -> None: new_temperature = temperature or self._attr_target_temperature - new_mode = _SWITCHBOT_HVAC_MODES.get( - hvac_mode or self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE - ) + new_mode = self._get_mode(hvac_mode) new_fan_speed = _SWITCHBOT_FAN_MODES.get( fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE ) + new_power_state = "on" if hvac_mode != HVACMode.OFF else "off" + command = f"{int(new_temperature)},{new_mode},{new_fan_speed},{new_power_state}" + _LOGGER.debug("Sending command to %s: %s", self._attr_unique_id, command) await self.send_api_command( AirConditionerCommands.SET_ALL, - parameters=f"{int(new_temperature)},{new_mode},{new_fan_speed},on", + parameters=command, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/tests/components/switchbot_cloud/test_climate.py b/tests/components/switchbot_cloud/test_climate.py new file mode 100644 index 000000000000..05859df39d1d --- /dev/null +++ b/tests/components/switchbot_cloud/test_climate.py @@ -0,0 +1,182 @@ +"""Test for the switchbot_cloud climate.""" + +from unittest.mock import patch + +from switchbot_api import Remote + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, State + +from . import configure_integration + +from tests.common import mock_restore_cache + + +async def test_air_conditioner_set_hvac_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting HVAC mode for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="DIY Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: "cool"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,2,1,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).state == "cool" + + # Test turning off + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: "off"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,2,1,off" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).state == "off" + + +async def test_air_conditioner_set_fan_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting fan mode for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,4,4,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).attributes[ATTR_FAN_MODE] == "high" + + +async def test_air_conditioner_set_temperature( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting temperature for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "25,4,1,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).attributes[ATTR_TEMPERATURE] == 25 + + +async def test_air_conditioner_restore_state( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test restoring state for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + mock_state = State( + "climate.climate_1", + "cool", + { + ATTR_FAN_MODE: "high", + ATTR_TEMPERATURE: 25, + }, + ) + + mock_restore_cache(hass, (mock_state,)) + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + state = hass.states.get(entity_id) + assert state.state == "cool" + assert state.attributes[ATTR_FAN_MODE] == "high" + assert state.attributes[ATTR_TEMPERATURE] == 25 + + +async def test_air_conditioner_no_last_state( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test behavior when no previous state exists.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "climate.climate_1" + state = hass.states.get(entity_id) + assert state.state == "fan_only" + assert state.attributes[ATTR_FAN_MODE] == "auto" + assert state.attributes[ATTR_TEMPERATURE] == 21