Add climate on/off for supported BMW vehicles (#92962)

* Add switch platform

* Add tests

* Remove separate button

* Bump coverage

---------

Co-authored-by: rikroe <rikroe@users.noreply.github.com>
pull/93405/head
rikroe 2023-05-23 15:21:29 +02:00 committed by GitHub
parent 28fa6f541f
commit f8f83906f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 227 additions and 6 deletions

View File

@ -44,6 +44,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
SERVICE_UPDATE_STATE = "update_state"

View File

@ -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",

View File

@ -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

View File

@ -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': <ANY>,
'entity_id': 'switch.i4_edrive40_climate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'off',
}),
])
# ---

View File

@ -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