From 1028841690323f9bca322bdd5dcd320fa77bcbd5 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 29 Apr 2023 17:41:34 +0200 Subject: [PATCH] Move BMW Target SoC to number platform (#91081) Co-authored-by: Franck Nijhof Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 1 + .../bmw_connected_drive/manifest.json | 2 +- .../components/bmw_connected_drive/number.py | 120 +++++++++++++++++ .../components/bmw_connected_drive/select.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 76 +++++++++++ .../snapshots/test_number.ambr | 22 ++++ .../snapshots/test_select.ambr | 32 ----- .../bmw_connected_drive/test_number.py | 123 ++++++++++++++++++ .../bmw_connected_drive/test_select.py | 2 - 11 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/number.py create mode 100644 tests/components/bmw_connected_drive/snapshots/test_number.ambr create mode 100644 tests/components/bmw_connected_drive/test_number.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index e91943034df..8d5d842e915 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index f1768d5a0c7..3c7d2ba27c3 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.0"] + "requirements": ["bimmer_connected==0.13.2"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py new file mode 100644 index 00000000000..f26a2027f72 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -0,0 +1,120 @@ +"""Number 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.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +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], float | int | None] + remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): + """Describes BMW number entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + mode: NumberMode = NumberMode.AUTO + + +NUMBER_TYPES: list[BMWNumberEntityDescription] = [ + BMWNumberEntityDescription( + key="target_soc", + name="Target SoC", + device_class=NumberDeviceClass.BATTERY, + is_available=lambda v: v.is_remote_set_target_soc_enabled, + native_max_value=100.0, + native_min_value=20.0, + native_step=5.0, + mode=NumberMode.SLIDER, + value_fn=lambda v: v.fuel_and_battery.charging_target, + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW number from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWNumber] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWNumber(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWNumber(BMWBaseEntity, NumberEntity): + """Representation of BMW Number entity.""" + + entity_description: BMWNumberEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWNumberEntityDescription, + ) -> None: + """Initialize an BMW Number.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + self._attr_mode = description.mode + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_set_native_value(self, value: float) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + value, + ) + try: + await self.entity_description.remote_service(self.vehicle, value) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index e8e8dd5ca40..52d35b477a2 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -9,7 +9,7 @@ from bimmer_connected.vehicle.charging_profile import ChargingMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,19 +37,6 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { - # --- Generic --- - "target_soc": BMWSelectEntityDescription( - key="target_soc", - name="Target SoC", - is_available=lambda v: v.is_remote_set_target_soc_enabled, - options=[str(i * 5 + 20) for i in range(17)], - current_option=lambda v: str(v.fuel_and_battery.charging_target), - remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( - target_soc=int(o) - ), - icon="mdi:battery-charging-medium", - unit_of_measurement=PERCENTAGE, - ), "ac_limit": BMWSelectEntityDescription( key="ac_limit", name="AC Charging Limit", diff --git a/requirements_all.txt b/requirements_all.txt index 9b6eeeb2e54..66640e25bca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ecb2e3b8bc..cd0fea6b33f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 2cd6622d14e..7ee3f625911 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -139,6 +139,22 @@ }), ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'STANDBY', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -808,6 +824,32 @@ ]), 'name': 'i4 eDrive40', 'timestamp': '2023-01-04T14:57:06+00:00', + 'tires': dict({ + 'front_left': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'front_right': dict({ + 'current_pressure': 255, + 'manufacturing_week': '2019-06-10T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'rear_left': dict({ + 'current_pressure': 324, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + 'rear_right': dict({ + 'current_pressure': 331, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + }), 'vehicle_location': dict({ 'account_region': 'row', 'heading': '**REDACTED**', @@ -969,6 +1011,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -1466,6 +1524,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, @@ -2456,6 +2515,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -2953,6 +3028,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr new file mode 100644 index 00000000000..a99d8bb3e0f --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index e6902fbacfd..522e74c61e2 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -1,38 +1,6 @@ # serializer version: 1 # name: test_entity_state_attrs list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Target SoC', - 'icon': 'mdi:battery-charging-medium', - 'options': list([ - '20', - '25', - '30', - '35', - '40', - '45', - '50', - '55', - '60', - '65', - '70', - '75', - '80', - '85', - '90', - '95', - '100', - ]), - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'select.i4_edrive40_target_soc', - 'last_changed': , - 'last_updated': , - 'state': '80', - }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py new file mode 100644 index 00000000000..b6c16af3e03 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_number.py @@ -0,0 +1,123 @@ +"""Test BMW numbers.""" +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 number options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all number entities + assert hass.states.async_all("number") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "80"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 + + +@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 number 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( + "number", + "set_value", + service_data={"value": "80"}, + blocking=True, + target={"entity_id": "number.i4_edrive40_target_soc"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 92daf157a70..bbef62b14ed 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -28,7 +28,6 @@ async def test_entity_state_attrs( [ ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), ("select.i4_edrive40_ac_charging_limit", "16"), - ("select.i4_edrive40_target_soc", "80"), ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), ], ) @@ -58,7 +57,6 @@ async def test_update_triggers_success( ("entity_id", "value"), [ ("select.i4_edrive40_ac_charging_limit", "17"), - ("select.i4_edrive40_target_soc", "81"), ], ) async def test_update_triggers_fail(