From f8f83906f84c5be2870b7ff1bab378640bc57805 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Tue, 23 May 2023 15:21:29 +0200 Subject: [PATCH] Add climate on/off for supported BMW vehicles (#92962) * Add switch platform * Add tests * Remove separate button * Bump coverage --------- Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/button.py | 6 - .../components/bmw_connected_drive/switch.py | 109 ++++++++++++++++++ .../snapshots/test_switch.ambr | 17 +++ .../bmw_connected_drive/test_switch.py | 100 ++++++++++++++++ 5 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/switch.py create mode 100644 tests/components/bmw_connected_drive/snapshots/test_switch.ambr create mode 100644 tests/components/bmw_connected_drive/test_switch.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 8d5d842e915..27f2d99cd2d 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -44,6 +44,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] SERVICE_UPDATE_STATE = "update_state" diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 873a72762ab..0ecc07357fe 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -53,12 +53,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), - BMWButtonEntityDescription( - key="deactivate_air_conditioning", - icon="mdi:hvac-off", - name="Deactivate air conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), - ), BMWButtonEntityDescription( key="find_vehicle", icon="mdi:crosshairs-question", diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py new file mode 100644 index 00000000000..af7a42b35b0 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -0,0 +1,109 @@ +"""Switch platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], bool] + remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): + """Describes BMW switch entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ + BMWSwitchEntityDescription( + key="climate", + name="Climate", + is_available=lambda v: v.is_remote_climate_stop_enabled, + value_fn=lambda v: v.climate.is_climate_on, + remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), + remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), + icon="mdi:fan", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW switch from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSwitch] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSwitch(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSwitch(BMWBaseEntity, SwitchEntity): + """Representation of BMW Switch entity.""" + + entity_description: BMWSwitchEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSwitchEntityDescription, + ) -> None: + """Initialize an BMW Switch.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.entity_description.remote_service_on(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.entity_description.remote_service_off(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23d7ec2e833 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + 'icon': 'mdi:fan', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py new file mode 100644 index 00000000000..fd9871dfecd --- /dev/null +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -0,0 +1,100 @@ +"""Test BMW switches.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test switch options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all switch entities + assert hass.states.async_all("switch") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("switch.i4_edrive40_climate", "ON"), + ("switch.i4_edrive40_climate", "OFF"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "switch", + f"turn_{value.lower()}", + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_update_triggers_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test not allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_on", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_off", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 2