Add EV charging remote services for BMW/Mini (#88759)

* Add select for EV charging to bmw_connected_drive

* Use snapshot for select tests, split select_option tests

* Apply suggestions from code review

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Further adjustments from code review

---------

Co-authored-by: rikroe <rikroe@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
rikroe 2023-03-30 19:37:03 +02:00 committed by GitHub
parent fd55d0f2dd
commit 565f311f5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 3356 additions and 331 deletions

View file

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

View file

@ -0,0 +1,139 @@
"""Select platform for BMW."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from bimmer_connected.vehicle import MyBMWVehicle
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.core import HomeAssistant, callback
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."""
current_option: Callable[[MyBMWVehicle], str]
remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]]
@dataclass
class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin):
"""Describes BMW sensor entity."""
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
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",
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
dynamic_options=lambda v: [
str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
],
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
ac_limit=int(o)
),
icon="mdi:current-ac",
unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
"charging_mode": BMWSelectEntityDescription(
key="charging_mode",
name="Charging Mode",
is_available=lambda v: v.is_charging_plan_supported,
options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN],
current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr]
remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update(
charging_mode=ChargingMode(o)
),
icon="mdi:vector-point-select",
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
entities: list[BMWSelect] = []
for vehicle in coordinator.account.vehicles:
if not coordinator.read_only:
entities.extend(
[
BMWSelect(coordinator, vehicle, description)
for description in SELECT_TYPES.values()
if description.is_available(vehicle)
]
)
async_add_entities(entities)
class BMWSelect(BMWBaseEntity, SelectEntity):
"""Representation of BMW select entity."""
entity_description: BMWSelectEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWSelectEntityDescription,
) -> None:
"""Initialize an BMW select."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
if description.dynamic_options:
self._attr_options = description.dynamic_options(vehicle)
self._attr_current_option = description.current_option(vehicle)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
_LOGGER.debug(
"Updating select '%s' of %s", self.entity_description.key, self.vehicle.name
)
self._attr_current_option = self.entity_description.current_option(self.vehicle)
super()._handle_coordinator_update()
async def async_select_option(self, option: str) -> None:
"""Update to the vehicle."""
_LOGGER.debug(
"Executing '%s' on vehicle '%s' to value '%s'",
self.entity_description.key,
self.vehicle.vin,
option,
)
await self.entity_description.remote_service(self.vehicle, option)

View file

@ -1,6 +1,9 @@
"""Fixtures for BMW tests."""
from unittest.mock import AsyncMock
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus
import pytest
from . import mock_login, mock_vehicles
@ -11,5 +14,11 @@ async def bmw_fixture(monkeypatch):
"""Patch the MyBMW Login and mock HTTP calls."""
monkeypatch.setattr(MyBMWAuthentication, "login", mock_login)
monkeypatch.setattr(
RemoteServices,
"trigger_remote_service",
AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})),
)
with mock_vehicles():
yield mock_vehicles

View file

@ -0,0 +1,80 @@
{
"chargeAndClimateSettings": {
"chargeAndClimateTimer": {
"chargingMode": "Sofort laden",
"chargingModeSemantics": "Sofort laden",
"departureTimer": ["Aus"],
"departureTimerSemantics": "Aus",
"preconditionForDeparture": "Aus",
"showDepartureTimers": false
},
"chargingFlap": {
"permanentlyUnlockLabel": "Aus"
},
"chargingSettings": {
"acCurrentLimitLabel": "16A",
"acCurrentLimitLabelSemantics": "16 Ampere",
"chargingTargetLabel": "80%",
"dcLoudnessLabel": "Nicht begrenzt",
"unlockCableAutomaticallyLabel": "Aus"
}
},
"chargeAndClimateTimerDetail": {
"chargingMode": {
"chargingPreference": "NO_PRESELECTION",
"endTimeSlot": "0001-01-01T00:00:00",
"startTimeSlot": "0001-01-01T00:00:00",
"type": "CHARGING_IMMEDIATELY"
},
"departureTimer": {
"type": "WEEKLY_DEPARTURE_TIMER",
"weeklyTimers": [
{
"daysOfTheWeek": [],
"id": 1,
"time": "0001-01-01T00:00:00",
"timerAction": "DEACTIVATE"
},
{
"daysOfTheWeek": [],
"id": 2,
"time": "0001-01-01T00:00:00",
"timerAction": "DEACTIVATE"
},
{
"daysOfTheWeek": [],
"id": 3,
"time": "0001-01-01T00:00:00",
"timerAction": "DEACTIVATE"
},
{
"daysOfTheWeek": [],
"id": 4,
"time": "0001-01-01T00:00:00",
"timerAction": "DEACTIVATE"
}
]
},
"isPreconditionForDepartureActive": false
},
"chargingFlapDetail": {
"isPermanentlyUnlock": false
},
"chargingSettingsDetail": {
"acLimit": {
"current": {
"unit": "A",
"value": 16
},
"isUnlimited": false,
"max": 32,
"min": 6,
"values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32]
},
"chargingTarget": 80,
"dcLoudness": "UNLIMITED_LOUD",
"isUnlockCableActive": false,
"minChargingTargetToWarning": 0
},
"servicePack": "WAVE_01"
}

View file

@ -0,0 +1,50 @@
[
{
"appVehicleType": "DEMO",
"attributes": {
"a4aType": "NOT_SUPPORTED",
"bodyType": "G26",
"brand": "BMW",
"color": 4284245350,
"countryOfOrigin": "DE",
"driveTrain": "ELECTRIC",
"driverGuideInfo": {
"androidAppScheme": "com.bmwgroup.driversguide.row",
"androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row",
"iosAppScheme": "bmwdriversguide:///open",
"iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8"
},
"headUnitRaw": "HU_MGU",
"headUnitType": "MGU",
"hmiVersion": "ID8",
"lastFetched": "2023-01-04T14:57:06.019Z",
"model": "i4 eDrive40",
"softwareVersionCurrent": {
"iStep": 470,
"puStep": {
"month": 11,
"year": 21
},
"seriesCluster": "G026"
},
"softwareVersionExFactory": {
"iStep": 470,
"puStep": {
"month": 11,
"year": 21
},
"seriesCluster": "G026"
},
"telematicsUnit": "WAVE01",
"year": 2021
},
"mappingInfo": {
"isAssociated": false,
"isLmmEnabled": false,
"isPrimaryUser": true,
"lmmStatusReasons": [],
"mappingStatus": "CONFIRMED"
},
"vin": "WBA00000000DEMO02"
}
]

View file

@ -0,0 +1,313 @@
{
"capabilities": {
"a4aType": "NOT_SUPPORTED",
"checkSustainabilityDPP": false,
"climateFunction": "AIR_CONDITIONING",
"climateNow": true,
"digitalKey": {
"bookedServicePackage": "SMACC_1_5",
"readerGraphics": "readerGraphics",
"state": "ACTIVATED"
},
"horn": true,
"isBmwChargingSupported": true,
"isCarSharingSupported": false,
"isChargeNowForBusinessSupported": true,
"isChargingHistorySupported": true,
"isChargingHospitalityEnabled": true,
"isChargingLoudnessEnabled": true,
"isChargingPlanSupported": true,
"isChargingPowerLimitEnabled": true,
"isChargingSettingsEnabled": true,
"isChargingTargetSocEnabled": true,
"isClimateTimerWeeklyActive": false,
"isCustomerEsimSupported": true,
"isDCSContractManagementSupported": true,
"isDataPrivacyEnabled": false,
"isEasyChargeEnabled": true,
"isEvGoChargingSupported": false,
"isMiniChargingSupported": false,
"isNonLscFeatureEnabled": false,
"isPersonalPictureUploadSupported": false,
"isRemoteEngineStartSupported": false,
"isRemoteHistoryDeletionSupported": false,
"isRemoteHistorySupported": true,
"isRemoteParkingSupported": false,
"isRemoteServicesActivationRequired": false,
"isRemoteServicesBookingRequired": false,
"isScanAndChargeSupported": true,
"isSustainabilityAccumulatedViewEnabled": false,
"isSustainabilitySupported": false,
"isWifiHotspotServiceSupported": false,
"lastStateCallState": "ACTIVATED",
"lights": true,
"lock": true,
"remote360": true,
"remoteChargingCommands": {},
"remoteSoftwareUpgrade": true,
"sendPoi": true,
"specialThemeSupport": [],
"speechThirdPartyAlexa": false,
"speechThirdPartyAlexaSDK": false,
"unlock": true,
"vehicleFinder": true,
"vehicleStateSource": "LAST_STATE_CALL"
},
"state": {
"chargingProfile": {
"chargingControlType": "WEEKLY_PLANNER",
"chargingMode": "IMMEDIATE_CHARGING",
"chargingPreference": "NO_PRESELECTION",
"chargingSettings": {
"acCurrentLimit": 16,
"hospitality": "NO_ACTION",
"idcc": "UNLIMITED_LOUD",
"targetSoc": 80
},
"departureTimes": [
{
"action": "DEACTIVATE",
"id": 1,
"timeStamp": {
"hour": 0,
"minute": 0
},
"timerWeekDays": []
},
{
"action": "DEACTIVATE",
"id": 2,
"timeStamp": {
"hour": 0,
"minute": 0
},
"timerWeekDays": []
},
{
"action": "DEACTIVATE",
"id": 3,
"timeStamp": {
"hour": 0,
"minute": 0
},
"timerWeekDays": []
},
{
"action": "DEACTIVATE",
"id": 4,
"timeStamp": {
"hour": 0,
"minute": 0
},
"timerWeekDays": []
}
]
},
"checkControlMessages": [
{
"severity": "LOW",
"type": "TIRE_PRESSURE"
}
],
"climateControlState": {
"activity": "STANDBY"
},
"climateTimers": [
{
"departureTime": {
"hour": 0,
"minute": 0
},
"isWeeklyTimer": false,
"timerAction": "DEACTIVATE",
"timerWeekDays": []
},
{
"departureTime": {
"hour": 0,
"minute": 0
},
"isWeeklyTimer": true,
"timerAction": "DEACTIVATE",
"timerWeekDays": []
},
{
"departureTime": {
"hour": 0,
"minute": 0
},
"isWeeklyTimer": true,
"timerAction": "DEACTIVATE",
"timerWeekDays": []
}
],
"combustionFuelLevel": {},
"currentMileage": 1121,
"doorsState": {
"combinedSecurityState": "LOCKED",
"combinedState": "CLOSED",
"hood": "CLOSED",
"leftFront": "CLOSED",
"leftRear": "CLOSED",
"rightFront": "CLOSED",
"rightRear": "CLOSED",
"trunk": "CLOSED"
},
"driverPreferences": {
"lscPrivacyMode": "OFF"
},
"electricChargingState": {
"chargingConnectionType": "UNKNOWN",
"chargingLevelPercent": 80,
"chargingStatus": "INVALID",
"chargingTarget": 80,
"isChargerConnected": false,
"range": 472,
"remainingChargingMinutes": 10
},
"isLeftSteering": true,
"isLscSupported": true,
"lastFetched": "2023-01-04T14:57:06.386Z",
"lastUpdatedAt": "2023-01-04T14:57:06.407Z",
"location": {
"address": {
"formatted": "Am Olympiapark 1, 80809 München"
},
"coordinates": {
"latitude": 48.177334,
"longitude": 11.556274
},
"heading": 180
},
"range": 472,
"requiredServices": [
{
"dateTime": "2024-12-01T00:00:00.000Z",
"description": "",
"mileage": 50000,
"status": "OK",
"type": "BRAKE_FLUID"
},
{
"dateTime": "2024-12-01T00:00:00.000Z",
"description": "",
"mileage": 50000,
"status": "OK",
"type": "VEHICLE_TUV"
},
{
"dateTime": "2024-12-01T00:00:00.000Z",
"description": "",
"mileage": 50000,
"status": "OK",
"type": "VEHICLE_CHECK"
},
{
"status": "OK",
"type": "TIRE_WEAR_REAR"
},
{
"status": "OK",
"type": "TIRE_WEAR_FRONT"
}
],
"tireState": {
"frontLeft": {
"details": {
"dimension": "225/35 R20 90Y XL",
"isOptimizedForOemBmw": true,
"manufacturer": "Pirelli",
"manufacturingWeek": 4021,
"mountingDate": "2022-03-07T00:00:00.000Z",
"partNumber": "2461756",
"season": 2,
"speedClassification": {
"atLeast": false,
"speedRating": 300
},
"treadDesign": "P-ZERO"
},
"status": {
"currentPressure": 241,
"pressureStatus": 0,
"targetPressure": 269,
"wearStatus": 0
}
},
"frontRight": {
"details": {
"dimension": "225/35 R20 90Y XL",
"isOptimizedForOemBmw": true,
"manufacturer": "Pirelli",
"manufacturingWeek": 2419,
"mountingDate": "2022-03-07T00:00:00.000Z",
"partNumber": "2461756",
"season": 2,
"speedClassification": {
"atLeast": false,
"speedRating": 300
},
"treadDesign": "P-ZERO"
},
"status": {
"currentPressure": 255,
"pressureStatus": 0,
"targetPressure": 269,
"wearStatus": 0
}
},
"rearLeft": {
"details": {
"dimension": "255/30 R20 92Y XL",
"isOptimizedForOemBmw": true,
"manufacturer": "Pirelli",
"manufacturingWeek": 1219,
"mountingDate": "2022-03-07T00:00:00.000Z",
"partNumber": "2461757",
"season": 2,
"speedClassification": {
"atLeast": false,
"speedRating": 300
},
"treadDesign": "P-ZERO"
},
"status": {
"currentPressure": 324,
"pressureStatus": 0,
"targetPressure": 303,
"wearStatus": 0
}
},
"rearRight": {
"details": {
"dimension": "255/30 R20 92Y XL",
"isOptimizedForOemBmw": true,
"manufacturer": "Pirelli",
"manufacturingWeek": 1219,
"mountingDate": "2022-03-07T00:00:00.000Z",
"partNumber": "2461757",
"season": 2,
"speedClassification": {
"atLeast": false,
"speedRating": 300
},
"treadDesign": "P-ZERO"
},
"status": {
"currentPressure": 331,
"pressureStatus": 0,
"targetPressure": 303,
"wearStatus": 0
}
}
},
"windowsState": {
"combinedState": "CLOSED",
"leftFront": "CLOSED",
"leftRear": "CLOSED",
"rear": "CLOSED",
"rightFront": "CLOSED",
"rightRear": "CLOSED"
}
}
}

View file

@ -0,0 +1,97 @@
# 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',
'friendly_name': 'i4 eDrive40 AC Charging Limit',
'icon': 'mdi:current-ac',
'options': list([
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'20',
'32',
]),
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'select.i4_edrive40_ac_charging_limit',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '16',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by MyBMW',
'friendly_name': 'i4 eDrive40 Charging Mode',
'icon': 'mdi:vector-point-select',
'options': list([
'IMMEDIATE_CHARGING',
'DELAYED_CHARGING',
]),
}),
'context': <ANY>,
'entity_id': 'select.i4_edrive40_charging_mode',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'IMMEDIATE_CHARGING',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by MyBMW',
'friendly_name': 'i3 (+ REX) Charging Mode',
'icon': 'mdi:vector-point-select',
'options': list([
'IMMEDIATE_CHARGING',
'DELAYED_CHARGING',
]),
}),
'context': <ANY>,
'entity_id': 'select.i3_rex_charging_mode',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'DELAYED_CHARGING',
}),
])
# ---

View file

@ -0,0 +1,84 @@
"""Test BMW selects."""
from bimmer_connected.vehicle.remote_services import RemoteServices
import pytest
import respx
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_mocked_integration
async def test_entity_state_attrs(
hass: HomeAssistant,
bmw_fixture: respx.Router,
snapshot: SnapshotAssertion,
) -> None:
"""Test select options and values.."""
# Setup component
assert await setup_mocked_integration(hass)
# Get all select entities
assert hass.states.async_all("select") == snapshot
@pytest.mark.parametrize(
("entity_id", "value"),
[
("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"),
],
)
async def test_update_triggers_success(
hass: HomeAssistant,
entity_id: str,
value: str,
bmw_fixture: respx.Router,
) -> None:
"""Test allowed values for select inputs."""
# Setup component
assert await setup_mocked_integration(hass)
# Test
await hass.services.async_call(
"select",
"select_option",
service_data={"option": value},
blocking=True,
target={"entity_id": entity_id},
)
assert RemoteServices.trigger_remote_service.call_count == 1
@pytest.mark.parametrize(
("entity_id", "value"),
[
("select.i4_edrive40_ac_charging_limit", "17"),
("select.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 select inputs."""
# Setup component
assert await setup_mocked_integration(hass)
# Test
with pytest.raises(ValueError):
await hass.services.async_call(
"select",
"select_option",
service_data={"option": value},
blocking=True,
target={"entity_id": entity_id},
)
assert RemoteServices.trigger_remote_service.call_count == 0