diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 0e8ad9726f1..e7f56075e63 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -6,9 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass import datetime import logging -from typing import cast -from bimmer_connected.models import ValueWithUnit +from bimmer_connected.models import StrEnum, ValueWithUnit from bimmer_connected.vehicle import MyBMWVehicle from homeassistant.components.sensor import ( @@ -18,14 +17,19 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent +from homeassistant.const import ( + PERCENTAGE, + STATE_UNKNOWN, + UnitOfElectricCurrent, + UnitOfLength, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from . import BMWBaseEntity -from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP +from .const import CLIMATE_ACTIVITY_STATE, DOMAIN from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -36,34 +40,18 @@ class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_type: str | None = None - value: Callable = lambda x, y: x is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled -def convert_and_round( - state: ValueWithUnit, - converter: Callable[[float | None, str], float], - precision: int, -) -> float | None: - """Safely convert and round a value from ValueWithUnit.""" - if state.value and state.unit: - return round( - converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision - ) - if state.value: - return state.value - return None - - SENSOR_TYPES: list[BMWSensorEntityDescription] = [ - # --- Generic --- BMWSensorEntityDescription( key="ac_current_limit", translation_key="ac_current_limit", key_class="charging_profile", - unit_type=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( @@ -85,74 +73,81 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ key="charging_status", translation_key="charging_status", key_class="fuel_and_battery", - value=lambda x, y: x.value, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="charging_target", translation_key="charging_target", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_battery_percent", translation_key="remaining_battery_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - # --- Specific --- BMWSensorEntityDescription( key="mileage", translation_key="mileage", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_total", translation_key="remaining_range_total", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_electric", translation_key="remaining_range_electric", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_range_fuel", translation_key="remaining_range_fuel", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel", translation_key="remaining_fuel", key_class="fuel_and_battery", - unit_type=VOLUME, - value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel_percent", translation_key="remaining_fuel_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( @@ -161,7 +156,6 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ key_class="climate", device_class=SensorDeviceClass.ENUM, options=CLIMATE_ACTIVITY_STATE, - value=lambda x, _: x.lower() if x != "UNKNOWN" else None, is_available=lambda v: v.is_remote_climate_stop_enabled, ), ] @@ -201,13 +195,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{vehicle.vin}-{description.key}" - # 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: """Handle updated data from the coordinator.""" @@ -225,8 +212,18 @@ class BMWSensor(BMWBaseEntity, SensorEntity): # For datetime without tzinfo, we assume it to be the same timezone as the HA instance if isinstance(state, datetime.datetime) and state.tzinfo is None: state = state.replace(tzinfo=dt_util.get_default_time_zone()) + # For enum types, we only want the value + elif isinstance(state, ValueWithUnit): + state = state.value + # Get lowercase values from StrEnum + elif isinstance(state, StrEnum): + state = state.value.lower() + if state == STATE_UNKNOWN: + state = None - self._attr_native_value = cast( - StateType, self.entity_description.value(state, self.hass) - ) + # special handling for charging_status to avoid a breaking change + if self.entity_description.key == "charging_status" and state: + state = state.upper() + + self._attr_native_value = state super()._handle_coordinator_update() diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index e3833add777..3455a4599b5 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -20,8 +20,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -36,6 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'i3 (+ REX) AC current limit', 'unit_of_measurement': , }), @@ -211,8 +215,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -227,6 +234,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'i3 (+ REX) Charging target', 'unit_of_measurement': '%', }), @@ -261,8 +269,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -277,6 +288,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Mileage', 'state_class': , 'unit_of_measurement': , @@ -312,6 +324,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -364,8 +379,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', @@ -380,6 +398,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', 'friendly_name': 'i3 (+ REX) Remaining fuel', 'state_class': , 'unit_of_measurement': , @@ -415,6 +434,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -466,8 +488,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -482,6 +507,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -517,8 +543,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', @@ -533,6 +562,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range fuel', 'state_class': , 'unit_of_measurement': , @@ -568,8 +598,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -584,6 +617,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -617,8 +651,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -633,6 +670,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'i4 eDrive40 AC current limit', 'unit_of_measurement': , }), @@ -808,8 +846,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -824,6 +865,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'i4 eDrive40 Charging target', 'unit_of_measurement': '%', }), @@ -919,8 +961,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -935,6 +980,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Mileage', 'state_class': , 'unit_of_measurement': , @@ -970,6 +1016,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1022,8 +1071,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -1038,6 +1090,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -1073,8 +1126,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1089,6 +1145,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -1122,8 +1179,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -1138,6 +1198,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'iX xDrive50 AC current limit', 'unit_of_measurement': , }), @@ -1313,8 +1374,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -1329,6 +1393,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'iX xDrive50 Charging target', 'unit_of_measurement': '%', }), @@ -1424,8 +1489,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -1440,6 +1508,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Mileage', 'state_class': , 'unit_of_measurement': , @@ -1475,6 +1544,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1527,8 +1599,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -1543,6 +1618,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -1578,8 +1654,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1594,6 +1673,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -1690,8 +1770,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -1706,6 +1789,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Mileage', 'state_class': , 'unit_of_measurement': , @@ -1741,8 +1825,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', @@ -1757,6 +1844,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', 'friendly_name': 'M340i xDrive Remaining fuel', 'state_class': , 'unit_of_measurement': , @@ -1792,6 +1880,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -1843,8 +1934,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', @@ -1859,6 +1953,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Remaining range fuel', 'state_class': , 'unit_of_measurement': , @@ -1894,8 +1989,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1910,6 +2008,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Remaining range total', 'state_class': , 'unit_of_measurement': , diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index b4cdc23ad68..2f83fa108e5 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -43,17 +43,17 @@ async def test_entity_state_attrs( ("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_remaining_range_total", IMPERIAL, "173.362562634216", "mi"), ("sensor.i3_rex_mileage", METRIC, "137009", "km"), - ("sensor.i3_rex_mileage", IMPERIAL, "85133.45", "mi"), + ("sensor.i3_rex_mileage", IMPERIAL, "85133.4456772449", "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_range_electric", IMPERIAL, "108.118587449296", "mi"), ("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"), - ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), + ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.58503231414889", "gal"), ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), - ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), + ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.2439751849201", "mi"), ("sensor.m340i_xdrive_remaining_fuel_percent", METRIC, "80", "%"), ("sensor.m340i_xdrive_remaining_fuel_percent", IMPERIAL, "80", "%"), ],