From cb2799bc37872d179f7fa8b5bc6dbf81bd75828f Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Tue, 16 Aug 2022 21:36:33 +0200 Subject: [PATCH] Fix displayed units for BMW Connected Drive (#76613) * Fix displayed units * Add tests for unit conversion * Streamline test config entry init * Refactor test to pytest fixture * Fix renamed mock Co-authored-by: rikroe --- .../components/bmw_connected_drive/sensor.py | 40 ++-- .../bmw_connected_drive/__init__.py | 86 ++++++++ .../bmw_connected_drive/conftest.py | 12 + .../I01/state_WBY00000000REXI01_0.json | 206 ++++++++++++++++++ .../vehicles/I01/vehicles_v2_bmw_0.json | 47 ++++ .../bmw_connected_drive/test_config_flow.py | 8 +- .../bmw_connected_drive/test_sensor.py | 52 +++++ 7 files changed, 420 insertions(+), 31 deletions(-) create mode 100644 tests/components/bmw_connected_drive/conftest.py create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json create mode 100644 tests/components/bmw_connected_drive/test_sensor.py diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 26fbe19b5b1..ae3dc0bb8b9 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -15,13 +15,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - LENGTH_KILOMETERS, - LENGTH_MILES, - PERCENTAGE, - VOLUME_GALLONS, - VOLUME_LITERS, -) +from homeassistant.const import LENGTH, PERCENTAGE, VOLUME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -39,8 +33,7 @@ class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_metric: str | None = None - unit_imperial: str | None = None + unit_type: str | None = None value: Callable = lambda x, y: x @@ -81,56 +74,49 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", key_class="fuel_and_battery", - unit_metric=PERCENTAGE, - unit_imperial=PERCENTAGE, + unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), # --- Specific --- "mileage": BMWSensorEntityDescription( key="mileage", icon="mdi:speedometer", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", key_class="fuel_and_battery", icon="mdi:gas-station", - unit_metric=VOLUME_LITERS, - unit_imperial=VOLUME_GALLONS, + unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", key_class="fuel_and_battery", icon="mdi:gas-station", - unit_metric=PERCENTAGE, - unit_imperial=PERCENTAGE, + unit_type=PERCENTAGE, ), } @@ -177,8 +163,12 @@ class BMWSensor(BMWBaseEntity, SensorEntity): self._attr_name = f"{vehicle.name} {description.key}" self._attr_unique_id = f"{vehicle.vin}-{description.key}" - # Force metric system as BMW API apparently only returns metric values now - self._attr_native_unit_of_measurement = description.unit_metric + # Set the correct unit of measurement based on the unit_type + if description.unit_type: + self._attr_native_unit_of_measurement = ( + coordinator.hass.config.units.as_dict().get(description.unit_type) + or description.unit_type + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 4774032b409..c2bb65b3fa7 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,17 +1,29 @@ """Tests for the for the BMW Connected Drive integration.""" +import json +from pathlib import Path + +from bimmer_connected.account import MyBMWAccount +from bimmer_connected.api.utils import log_to_to_file + from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, + CONF_REFRESH_TOKEN, DOMAIN as BMW_DOMAIN, ) from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, get_fixture_path, load_fixture FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", CONF_PASSWORD: "p4ssw0rd", CONF_REGION: "rest_of_world", } +FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", @@ -21,8 +33,82 @@ FIXTURE_CONFIG_ENTRY = { CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME], CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION], + CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, }, "options": {CONF_READ_ONLY: False}, "source": config_entries.SOURCE_USER, "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}", } + + +async def mock_vehicles_from_fixture(account: MyBMWAccount) -> None: + """Load MyBMWVehicle from fixtures and add them to the account.""" + + fixture_path = Path(get_fixture_path("", integration=BMW_DOMAIN)) + + fixture_vehicles_bmw = list(fixture_path.rglob("vehicles_v2_bmw_*.json")) + fixture_vehicles_mini = list(fixture_path.rglob("vehicles_v2_mini_*.json")) + + # Load vehicle base lists as provided by vehicles/v2 API + vehicles = { + "bmw": [ + vehicle + for bmw_file in fixture_vehicles_bmw + for vehicle in json.loads(load_fixture(bmw_file, integration=BMW_DOMAIN)) + ], + "mini": [ + vehicle + for mini_file in fixture_vehicles_mini + for vehicle in json.loads(load_fixture(mini_file, integration=BMW_DOMAIN)) + ], + } + fetched_at = utcnow() + + # simulate storing fingerprints + if account.config.log_response_path: + for brand in ["bmw", "mini"]: + log_to_to_file( + json.dumps(vehicles[brand]), + account.config.log_response_path, + f"vehicles_v2_{brand}", + ) + + # Create a vehicle with base + specific state as provided by state/VIN API + for vehicle_base in [vehicle for brand in vehicles.values() for vehicle in brand]: + vehicle_state_path = ( + Path("vehicles") + / vehicle_base["attributes"]["bodyType"] + / f"state_{vehicle_base['vin']}_0.json" + ) + vehicle_state = json.loads( + load_fixture( + vehicle_state_path, + integration=BMW_DOMAIN, + ) + ) + + account.add_vehicle( + vehicle_base, + vehicle_state, + fetched_at, + ) + + # simulate storing fingerprints + if account.config.log_response_path: + log_to_to_file( + json.dumps(vehicle_state), + account.config.log_response_path, + f"state_{vehicle_base['vin']}", + ) + + +async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock a fully setup config entry and all components based on fixtures.""" + + mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py new file mode 100644 index 00000000000..bf9d32ed9fa --- /dev/null +++ b/tests/components/bmw_connected_drive/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures for BMW tests.""" + +from bimmer_connected.account import MyBMWAccount +import pytest + +from . import mock_vehicles_from_fixture + + +@pytest.fixture +async def bmw_fixture(monkeypatch): + """Patch the vehicle fixtures into a MyBMWAccount.""" + monkeypatch.setattr(MyBMWAccount, "get_vehicles", mock_vehicles_from_fixture) diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json new file mode 100644 index 00000000000..adc2bde3650 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json @@ -0,0 +1,206 @@ +{ + "capabilities": { + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "climateTimerTrigger": "DEPARTURE_TIMER", + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": false, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": false, + "isChargingLoudnessEnabled": false, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": false, + "isChargingSettingsEnabled": false, + "isChargingTargetSocEnabled": false, + "isClimateTimerSupported": true, + "isCustomerEsimSupported": false, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": false, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remoteChargingCommands": {}, + "sendPoi": true, + "specialThemeSupport": [], + "unlock": true, + "vehicleFinder": false, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "DELAYED_CHARGING", + "chargingPreference": "CHARGING_WINDOW", + "chargingSettings": { + "hospitality": "NO_ACTION", + "idcc": "NO_ACTION", + "targetSoc": 100 + }, + "climatisationOn": false, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 7, + "minute": 35 + }, + "timerWeekDays": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 18, + "minute": 0 + }, + "timerWeekDays": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 7, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timerWeekDays": [] + } + ], + "reductionOfChargeCurrent": { + "end": { + "hour": 1, + "minute": 30 + }, + "start": { + "hour": 18, + "minute": 1 + } + } + }, + "checkControlMessages": [], + "climateTimers": [ + { + "departureTime": { + "hour": 6, + "minute": 40 + }, + "isWeeklyTimer": true, + "timerAction": "ACTIVATE", + "timerWeekDays": ["THURSDAY", "SUNDAY"] + }, + { + "departureTime": { + "hour": 12, + "minute": 50 + }, + "isWeeklyTimer": false, + "timerAction": "ACTIVATE", + "timerWeekDays": ["MONDAY"] + }, + { + "departureTime": { + "hour": 18, + "minute": 59 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": ["WEDNESDAY"] + } + ], + "combustionFuelLevel": { + "range": 105, + "remainingFuelLiters": 6, + "remainingFuelPercent": 65 + }, + "currentMileage": 137009, + "doorsState": { + "combinedSecurityState": "UNLOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "CONDUCTIVE", + "chargingLevelPercent": 82, + "chargingStatus": "WAITING_FOR_CHARGING", + "chargingTarget": 100, + "isChargerConnected": true, + "range": 174 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2022-06-22T14:24:23.982Z", + "lastUpdatedAt": "2022-06-22T13:58:52Z", + "range": 174, + "requiredServices": [ + { + "dateTime": "2022-10-01T00:00:00.000Z", + "description": "Next service due by the specified date.", + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2023-05-01T00:00:00.000Z", + "description": "Next vehicle check due after the specified distance or date.", + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "dateTime": "2023-05-01T00:00:00.000Z", + "description": "Next state inspection due by the specified date.", + "status": "OK", + "type": "VEHICLE_TUV" + } + ], + "roofState": { + "roofState": "CLOSED", + "roofStateType": "SUN_ROOF" + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "rightFront": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json new file mode 100644 index 00000000000..145bc13378e --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json @@ -0,0 +1,47 @@ +[ + { + "appVehicleType": "CONNECTED", + "attributes": { + "a4aType": "USB_ONLY", + "bodyType": "I01", + "brand": "BMW_I", + "color": 4284110934, + "countryOfOrigin": "CZ", + "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", + "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" + }, + "headUnitType": "NBT", + "hmiVersion": "ID4", + "lastFetched": "2022-07-10T09:25:53.104Z", + "model": "i3 (+ REX)", + "softwareVersionCurrent": { + "iStep": 510, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "I001" + }, + "softwareVersionExFactory": { + "iStep": 502, + "puStep": { + "month": 3, + "year": 15 + }, + "seriesCluster": "I001" + }, + "year": 2015 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "mappingStatus": "CONFIRMED" + }, + "vin": "WBY00000000REXI01" + } +] diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 3f22f984a54..daac0c04f7b 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -12,15 +12,11 @@ from homeassistant.components.bmw_connected_drive.const import ( ) from homeassistant.const import CONF_USERNAME -from . import FIXTURE_CONFIG_ENTRY, FIXTURE_USER_INPUT +from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT from tests.common import MockConfigEntry -FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" -FIXTURE_COMPLETE_ENTRY = { - **FIXTURE_USER_INPUT, - CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, -} +FIXTURE_COMPLETE_ENTRY = FIXTURE_CONFIG_ENTRY["data"] FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py new file mode 100644 index 00000000000..cb1299a274b --- /dev/null +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -0,0 +1,52 @@ +"""Test BMW sensors.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM as IMPERIAL, + METRIC_SYSTEM as METRIC, + UnitSystem, +) + +from . import setup_mocked_integration + + +@pytest.mark.parametrize( + "entity_id,unit_system,value,unit_of_measurement", + [ + ("sensor.i3_rex_remaining_range_total", METRIC, "279", "km"), + ("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.36", "mi"), + ("sensor.i3_rex_mileage", METRIC, "137009", "km"), + ("sensor.i3_rex_mileage", IMPERIAL, "85133.42", "mi"), + ("sensor.i3_rex_remaining_battery_percent", METRIC, "82", "%"), + ("sensor.i3_rex_remaining_battery_percent", IMPERIAL, "82", "%"), + ("sensor.i3_rex_remaining_range_electric", METRIC, "174", "km"), + ("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.12", "mi"), + ("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"), + ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), + ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), + ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), + ("sensor.i3_rex_remaining_fuel_percent", METRIC, "65", "%"), + ("sensor.i3_rex_remaining_fuel_percent", IMPERIAL, "65", "%"), + ], +) +async def test_unit_conversion( + hass: HomeAssistant, + entity_id: str, + unit_system: UnitSystem, + value: str, + unit_of_measurement: str, + bmw_fixture, +) -> None: + """Test conversion between metric and imperial units for sensors.""" + + # Set unit system + hass.config.units = unit_system + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + entity = hass.states.get(entity_id) + assert entity.state == value + assert entity.attributes.get("unit_of_measurement") == unit_of_measurement