diff --git a/.coveragerc b/.coveragerc index eaae941c70da..86b92c07a3d7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1264,6 +1264,7 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/climate.py homeassistant/components/switchbot_cloud/coordinator.py homeassistant/components/switchbot_cloud/entity.py homeassistant/components/switchbot_cloud/switch.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index c34348137e72..8d3b2443b186 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,27 +1,28 @@ """The SwitchBot via API integration.""" from asyncio import gather -from dataclasses import dataclass +from dataclasses import dataclass, field from logging import getLogger from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] @dataclass class SwitchbotDevices: """Switchbot devices data.""" - switches: list[Device | Remote] + climates: list[Remote] = field(default_factory=list) + switches: list[Device | Remote] = field(default_factory=list) @dataclass @@ -32,18 +33,47 @@ class SwitchbotCloudData: devices: SwitchbotDevices +@callback def prepare_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, - coordinators: list[SwitchBotCoordinator], + coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> tuple[Device | Remote, SwitchBotCoordinator]: """Instantiate coordinator and adds to list for gathering.""" - coordinator = SwitchBotCoordinator(hass, api, device) - coordinators.append(coordinator) + coordinator = coordinators_by_id.setdefault( + device.device_id, SwitchBotCoordinator(hass, api, device) + ) return (device, coordinator) +@callback +def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + devices: list[Device | Remote], + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> SwitchbotDevices: + """Make device data.""" + devices_data = SwitchbotDevices() + for device in devices: + if isinstance(device, Remote) and device.device_type.endswith( + "Air Conditioner" + ): + devices_data.climates.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + if ( + isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ): + devices_data.switches.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + return devices_data + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -60,25 +90,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: except CannotConnect as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) - coordinators: list[SwitchBotCoordinator] = [] + coordinators_by_id: dict[str, SwitchBotCoordinator] = {} hass.data.setdefault(DOMAIN, {}) - data = SwitchbotCloudData( - api=api, - devices=SwitchbotDevices( - switches=[ - prepare_device(hass, api, device, coordinators) - for device in devices - if isinstance(device, Device) - and device.device_type.startswith("Plug") - or isinstance(device, Remote) - ], - ), + hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( + api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) ) - hass.data[DOMAIN][config.entry_id] = data - for device_type, devices in vars(data.devices).items(): - _LOGGER.debug("%s: %s", device_type, devices) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather(*[coordinator.async_refresh() for coordinator in coordinators]) + await gather( + *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] + ) return True diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py new file mode 100644 index 000000000000..8ad0e1ad43ff --- /dev/null +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -0,0 +1,118 @@ +"""Support for SwitchBot Air Conditioner remotes.""" + +from typing import Any + +from switchbot_api import AirConditionerCommands + +import homeassistant.components.climate as FanState +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + +_SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = { + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.DRY: 3, + HVACMode.FAN_ONLY: 4, + HVACMode.HEAT: 5, +} + +_DEFAULT_SWITCHBOT_HVAC_MODE = _SWITCHBOT_HVAC_MODES[HVACMode.FAN_ONLY] + +_SWITCHBOT_FAN_MODES: dict[str, int] = { + FanState.FAN_AUTO: 1, + FanState.FAN_LOW: 2, + FanState.FAN_MEDIUM: 3, + FanState.FAN_HIGH: 4, +} + +_DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudAirConditionner(data.api, device, coordinator) + for device, coordinator in data.devices.climates + ) + + +class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): + """Representation of a SwitchBot air conditionner, as it is an IR device, we don't know the actual state.""" + + _attr_assumed_state = True + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_fan_modes = [ + FanState.FAN_AUTO, + FanState.FAN_LOW, + FanState.FAN_MEDIUM, + FanState.FAN_HIGH, + ] + _attr_fan_mode = FanState.FAN_AUTO + _attr_hvac_modes = [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + ] + _attr_hvac_mode = HVACMode.FAN_ONLY + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature = 21 + _attr_name = None + + async def _do_send_command( + self, + hvac_mode: HVACMode | None = None, + fan_mode: str | None = None, + 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_fan_speed = _SWITCHBOT_FAN_MODES.get( + fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE + ) + await self.send_command( + AirConditionerCommands.SET_ALL, + parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set target hvac mode.""" + await self._do_send_command(hvac_mode=hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set target fan mode.""" + await self._do_send_command(fan_mode=fan_mode) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._do_send_command(temperature=temperature) + self._attr_target_temperature = temperature diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 48f0021bdb46..e9f0a0a475d5 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState @@ -32,12 +32,24 @@ async def test_setup_entry_success( ) -> None: """Test successful setup of entry.""" mock_list_devices.return_value = [ + Remote( + deviceId="air-conditonner-id-1", + deviceName="air-conditonner-name-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), Device( - deviceId="test-id", - deviceName="test-name", + deviceId="plug-id-1", + deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", - ) + ), + Remote( + deviceId="plug-id-2", + deviceName="plug-name-2", + remoteType="DIY Plug", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass)