Move BMW Target SoC to number platform (#91081)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: rikroe <rikroe@users.noreply.github.com>
This commit is contained in:
rikroe 2023-04-29 17:41:34 +02:00 committed by GitHub
parent acc4b001cd
commit 1028841690
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 346 additions and 51 deletions

View File

@ -41,6 +41,7 @@ PLATFORMS = [
Platform.DEVICE_TRACKER,
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
]

View File

@ -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"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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': <NumberMode.SLIDER: 'slider'>,
'step': 5.0,
}),
'context': <ANY>,
'entity_id': 'number.i4_edrive40_target_soc',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '80',
}),
])
# ---

View File

@ -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': <ANY>,
'entity_id': 'select.i4_edrive40_target_soc',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '80',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by MyBMW',

View File

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

View File

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