From 4a3e6e8cb7c3946e8103da219bb3acc626732efb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 23:10:00 +0100 Subject: [PATCH] Update to Home Assistant 2024.12.0b3 (#80) Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 112 +++---- .../bmw_connected_drive/binary_sensor.py | 68 ++-- .../bmw_connected_drive/button.py | 61 ++-- .../bmw_connected_drive/config_flow.py | 169 +++++++--- .../bmw_connected_drive/const.py | 13 +- .../bmw_connected_drive/coordinator.py | 37 ++- .../bmw_connected_drive/device_tracker.py | 21 +- .../bmw_connected_drive/diagnostics.py | 16 +- .../bmw_connected_drive/entity.py | 40 +++ .../bmw_connected_drive/icons.json | 102 ++++++ custom_components/bmw_connected_drive/lock.py | 65 ++-- .../bmw_connected_drive/manifest.json | 4 +- .../bmw_connected_drive/notify.py | 90 +++--- .../bmw_connected_drive/number.py | 26 +- .../bmw_connected_drive/select.py | 52 ++- .../bmw_connected_drive/sensor.py | 299 ++++++++++-------- .../bmw_connected_drive/strings.json | 193 ++++++++++- .../bmw_connected_drive/switch.py | 27 +- 18 files changed, 919 insertions(+), 476 deletions(-) create mode 100644 custom_components/bmw_connected_drive/entity.py create mode 100644 custom_components/bmw_connected_drive/icons.json diff --git a/custom_components/bmw_connected_drive/__init__.py b/custom_components/bmw_connected_drive/__init__.py index 27f2d99..9e43cfc 100644 --- a/custom_components/bmw_connected_drive/__init__.py +++ b/custom_components/bmw_connected_drive/__init__.py @@ -1,29 +1,28 @@ """Reads vehicle status from MyBMW portal.""" + from __future__ import annotations +from dataclasses import dataclass import logging -from typing import Any -from bimmer_connected.vehicle import MyBMWVehicle import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + discovery, + entity_registry as er, +) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_VIN, ATTRIBUTION, CONF_READ_ONLY, DATA_HASS_CONFIG, DOMAIN +from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - SERVICE_SCHEMA = vol.Schema( vol.Any( {vol.Required(ATTR_VIN): cv.string}, @@ -50,13 +49,14 @@ SERVICE_UPDATE_STATE = "update_state" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the BMW Connected Drive component from configuration.yaml.""" - # Store full yaml config in data for platform.NOTIFY - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][DATA_HASS_CONFIG] = config +type BMWConfigEntry = ConfigEntry[BMWData] - return True + +@dataclass +class BMWData: + """Class to store BMW runtime data.""" + + coordinator: BMWDataUpdateCoordinator @callback @@ -67,14 +67,17 @@ def _async_migrate_options_from_data_if_missing( options = dict(entry.options) if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS): - options = dict(DEFAULT_OPTIONS, **options) + options = dict( + DEFAULT_OPTIONS, + **{k: v for k, v in options.items() if k in DEFAULT_OPTIONS}, + ) options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False) hass.config_entries.async_update_entry(entry, data=data, options=options) async def _async_migrate_entries( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: BMWConfigEntry ) -> bool: """Migrate old entry.""" entity_registry = er.async_get(hass) @@ -82,8 +85,20 @@ async def _async_migrate_entries( @callback def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: replacements = { - "charging_level_hv": "remaining_battery_percent", - "fuel_percent": "remaining_fuel_percent", + "charging_level_hv": "fuel_and_battery.remaining_battery_percent", + "fuel_percent": "fuel_and_battery.remaining_fuel_percent", + "ac_current_limit": "charging_profile.ac_current_limit", + "charging_start_time": "fuel_and_battery.charging_start_time", + "charging_end_time": "fuel_and_battery.charging_end_time", + "charging_status": "fuel_and_battery.charging_status", + "charging_target": "fuel_and_battery.charging_target", + "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent", + "remaining_range_total": "fuel_and_battery.remaining_range_total", + "remaining_range_electric": "fuel_and_battery.remaining_range_electric", + "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel", + "remaining_fuel": "fuel_and_battery.remaining_fuel", + "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent", + "activity": "climate.activity", } if (key := entry.unique_id.split("-")[-1]) in replacements: new_unique_id = entry.unique_id.replace(key, replacements[key]) @@ -126,8 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = BMWData(coordinator) # Set up all platforms except notify await hass.config_entries.async_forward_entry_setups( @@ -142,54 +156,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Platform.NOTIFY, DOMAIN, {CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id}, - hass.data[DOMAIN][DATA_HASS_CONFIG], + {}, ) ) + # Clean up vehicles which are not assigned to the account anymore + account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles} + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for device in device_entries: + if not device.identifiers.intersection(account_vehicles): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( + + return await hass.config_entries.async_unload_platforms( entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]): - """Common base for BMW entities.""" - - coordinator: BMWDataUpdateCoordinator - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - ) -> None: - """Initialize entity.""" - super().__init__(coordinator) - - self.vehicle = vehicle - - self._attrs: dict[str, Any] = { - "car": self.vehicle.name, - "vin": self.vehicle.vin, - } - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.vehicle.vin)}, - manufacturer=vehicle.brand.name, - model=vehicle.name, - name=vehicle.name, - ) - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self._handle_coordinator_update() diff --git a/custom_components/bmw_connected_drive/binary_sensor.py b/custom_components/bmw_connected_drive/binary_sensor.py index 640f4e3..65bdfca 100644 --- a/custom_components/bmw_connected_drive/binary_sensor.py +++ b/custom_components/bmw_connected_drive/binary_sensor.py @@ -1,4 +1,5 @@ """Reads vehicle status from BMW MyBMW portal.""" + from __future__ import annotations from collections.abc import Callable @@ -16,20 +17,22 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import UnitSystem -from . import BMWBaseEntity -from .const import DOMAIN, UNIT_MAP +from . import BMWConfigEntry +from .const import UNIT_MAP from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "BRAKE_FLUID", + "BRAKE_PADS_FRONT", + "BRAKE_PADS_REAR", "EMISSION_CHECK", "ENGINE_OIL", "OIL", @@ -40,7 +43,11 @@ } LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() -ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = {"ENGINE_OIL", "TIRE_PRESSURE"} +ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = { + "ENGINE_OIL", + "TIRE_PRESSURE", + "WASHING_FLUID", +} LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set() @@ -103,28 +110,20 @@ def _format_cbs_report( return result -@dataclass -class BMWRequiredKeysMixin: - """Mixin for required keys.""" - - value_fn: Callable[[MyBMWVehicle], bool] - - -@dataclass -class BMWBinarySensorEntityDescription( - BinarySensorEntityDescription, BMWRequiredKeysMixin -): +@dataclass(frozen=True, kw_only=True) +class BMWBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes BMW binary_sensor entity.""" + value_fn: Callable[[MyBMWVehicle], bool] attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None + is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( key="lids", - name="Lids", + translation_key="lids", device_class=BinarySensorDeviceClass.OPENING, - icon="mdi:car-door-lock", # device class opening: On means open, Off means closed value_fn=lambda v: not v.doors_and_windows.all_lids_closed, attr_fn=lambda v, u: { @@ -133,9 +132,8 @@ class BMWBinarySensorEntityDescription( ), BMWBinarySensorEntityDescription( key="windows", - name="Windows", + translation_key="windows", device_class=BinarySensorDeviceClass.OPENING, - icon="mdi:car-door", # device class opening: On means open, Off means closed value_fn=lambda v: not v.doors_and_windows.all_windows_closed, attr_fn=lambda v, u: { @@ -144,9 +142,8 @@ class BMWBinarySensorEntityDescription( ), BMWBinarySensorEntityDescription( key="door_lock_state", - name="Door lock state", + translation_key="door_lock_state", device_class=BinarySensorDeviceClass.LOCK, - icon="mdi:car-key", # device class lock: On means unlocked, Off means locked # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED value_fn=lambda v: v.doors_and_windows.door_lock_state @@ -157,18 +154,16 @@ class BMWBinarySensorEntityDescription( ), BMWBinarySensorEntityDescription( key="condition_based_services", - name="Condition based services", + translation_key="condition_based_services", device_class=BinarySensorDeviceClass.PROBLEM, - icon="mdi:wrench", # device class problem: On means problem detected, Off means no problem value_fn=lambda v: v.condition_based_services.is_service_required, attr_fn=_condition_based_services, ), BMWBinarySensorEntityDescription( key="check_control_messages", - name="Check control messages", + translation_key="check_control_messages", device_class=BinarySensorDeviceClass.PROBLEM, - icon="mdi:car-tire-alert", # device class problem: On means problem detected, Off means no problem value_fn=lambda v: v.check_control_messages.has_check_control_messages, attr_fn=lambda v, u: _check_control_messages(v), @@ -176,43 +171,43 @@ class BMWBinarySensorEntityDescription( # electric BMWBinarySensorEntityDescription( key="charging_status", - name="Charging status", + translation_key="charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - icon="mdi:ev-station", # device class power: On means power detected, Off means no power value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING, + is_available=lambda v: v.has_electric_drivetrain, ), BMWBinarySensorEntityDescription( key="connection_status", - name="Connection status", + translation_key="connection_status", device_class=BinarySensorDeviceClass.PLUG, - icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, + is_available=lambda v: v.has_electric_drivetrain, ), BMWBinarySensorEntityDescription( key="is_pre_entry_climatization_enabled", - name="Pre entry climatization", - icon="mdi:car-seat-heater", + translation_key="is_pre_entry_climatization_enabled", value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled if v.charging_profile else False, + is_available=lambda v: v.has_electric_drivetrain, ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW binary sensors from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities = [ BMWBinarySensor(coordinator, vehicle, description, hass.config.units) for vehicle in coordinator.account.vehicles for description in SENSOR_TYPES - if description.key in vehicle.available_attributes + if description.is_available(vehicle) ] async_add_entities(entities) @@ -246,9 +241,8 @@ def _handle_coordinator_update(self) -> None: self._attr_is_on = self.entity_description.value_fn(self.vehicle) if self.entity_description.attr_fn: - self._attr_extra_state_attributes = dict( - self._attrs, - **self.entity_description.attr_fn(self.vehicle, self._unit_system), + self._attr_extra_state_attributes = self.entity_description.attr_fn( + self.vehicle, self._unit_system ) super()._handle_coordinator_update() diff --git a/custom_components/bmw_connected_drive/button.py b/custom_components/bmw_connected_drive/button.py index 5285820..e6bd92b 100644 --- a/custom_components/bmw_connected_drive/button.py +++ b/custom_components/bmw_connected_drive/button.py @@ -1,4 +1,5 @@ """Support for MyBMW button entities.""" + from __future__ import annotations from collections.abc import Callable, Coroutine @@ -6,16 +7,17 @@ import logging from typing import TYPE_CHECKING, Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.remote_services import RemoteServiceStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -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 . import BMWConfigEntry +from .entity import BMWBaseEntity if TYPE_CHECKING: from .coordinator import BMWDataUpdateCoordinator @@ -23,59 +25,53 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True, kw_only=True) class BMWButtonEntityDescription(ButtonEntityDescription): """Class describing BMW button entities.""" + remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] enabled_when_read_only: bool = False - remote_function: Callable[ - [MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus] - ] | None = None - account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None + is_available: Callable[[MyBMWVehicle], bool] = lambda _: True BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( BMWButtonEntityDescription( key="light_flash", - icon="mdi:car-light-alert", - name="Flash lights", + translation_key="light_flash", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), ), BMWButtonEntityDescription( key="sound_horn", - icon="mdi:bullhorn", - name="Sound horn", + translation_key="sound_horn", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), ), BMWButtonEntityDescription( key="activate_air_conditioning", - icon="mdi:hvac", - name="Activate air conditioning", + translation_key="activate_air_conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), BMWButtonEntityDescription( - key="find_vehicle", - icon="mdi:crosshairs-question", - name="Find vehicle", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), + key="deactivate_air_conditioning", + translation_key="deactivate_air_conditioning", + name="Deactivate air conditioning", + remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), + is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, ), BMWButtonEntityDescription( - key="refresh", - icon="mdi:refresh", - name="Refresh from cloud", - account_function=lambda coordinator: coordinator.async_request_refresh(), - enabled_when_read_only=True, + key="find_vehicle", + translation_key="find_vehicle", + remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW buttons from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities: list[BMWButton] = [] @@ -84,7 +80,7 @@ async def async_setup_entry( [ BMWButton(coordinator, vehicle, description) for description in BUTTON_TYPES - if not coordinator.read_only + if (not coordinator.read_only and description.is_available(vehicle)) or (coordinator.read_only and description.enabled_when_read_only) ] ) @@ -110,16 +106,9 @@ def __init__( async def async_press(self) -> None: """Press the button.""" - if self.entity_description.remote_function: + try: await self.entity_description.remote_function(self.vehicle) - elif self.entity_description.account_function: - _LOGGER.warning( - "The 'Refresh from cloud' button is deprecated. Use the" - " 'homeassistant.update_entity' service with any BMW entity for a full" - " reload. See" - " https://www.home-assistant.io/integrations/bmw_connected_drive/#update-the-state--refresh-from-api" - " for details" - ) - await self.entity_description.account_function(self.coordinator) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/custom_components/bmw_connected_drive/config_flow.py b/custom_components/bmw_connected_drive/config_flow.py index 9267063..8831895 100644 --- a/custom_components/bmw_connected_drive/config_flow.py +++ b/custom_components/bmw_connected_drive/config_flow.py @@ -1,4 +1,5 @@ """Config flow for BMW ConnectedDrive integration.""" + from __future__ import annotations from collections.abc import Mapping @@ -6,30 +7,61 @@ from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.ssl import get_default_context from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import ( + CONF_ALLOWED_REGIONS, + CONF_CAPTCHA_REGIONS, + CONF_CAPTCHA_TOKEN, + CONF_CAPTCHA_URL, + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, +) DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION): vol.In(CONF_ALLOWED_REGIONS), - } + vol.Required(CONF_REGION): SelectSelector( + SelectSelectorConfig( + options=CONF_ALLOWED_REGIONS, + translation_key="regions", + ) + ), + }, + extra=vol.REMOVE_EXTRA, +) +CAPTCHA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CAPTCHA_TOKEN): str, + }, + extra=vol.REMOVE_EXTRA, ) -async def validate_input( - hass: core.HomeAssistant, data: dict[str, Any] -) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -38,10 +70,14 @@ async def validate_input( data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), + hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), + verify=get_default_context(), ) try: await auth.login() + except MyBMWCaptchaMissingError as ex: + raise MissingCaptcha from ex except MyBMWAuthError as ex: raise InvalidAuth from ex except (MyBMWAPIError, RequestError) as ex: @@ -56,90 +92,131 @@ async def validate_input( return retval -class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class BMWConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for MyBMW.""" VERSION = 1 - _reauth_entry: config_entries.ConfigEntry | None = None + data: dict[str, Any] = {} + + _existing_entry_data: Mapping[str, Any] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} + errors: dict[str, str] = self.data.pop("errors", {}) - if user_input is not None: + if user_input is not None and not errors: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" + await self.async_set_unique_id(unique_id) - if not self._reauth_entry: - await self.async_set_unique_id(unique_id) + if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: + self._abort_if_unique_id_mismatch(reason="account_mismatch") + else: self._abort_if_unique_id_configured() + # Store user input for later use + self.data.update(user_input) + + # North America and Rest of World require captcha token + if ( + self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS + and CONF_CAPTCHA_TOKEN not in self.data + ): + return await self.async_step_captcha() + info = None try: - info = await validate_input(self.hass, user_input) - entry_data = { - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - CONF_GCID: info.get(CONF_GCID), - } + info = await validate_input(self.hass, self.data) + except MissingCaptcha: + errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + finally: + self.data.pop(CONF_CAPTCHA_TOKEN, None) if info: - if self._reauth_entry: - self.hass.config_entries.async_update_entry( - self._reauth_entry, data=entry_data + entry_data = { + **self.data, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + CONF_GCID: info.get(CONF_GCID), + } + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=entry_data ) - self.hass.async_create_task( - self.hass.config_entries.async_reload( - self._reauth_entry.entry_id - ) + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=entry_data, ) - return self.async_abort(reason="reauth_successful") - return self.async_create_entry( title=info["title"], data=entry_data, ) schema = self.add_suggested_values_to_schema( - DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} + DATA_SCHEMA, + self._existing_entry_data or self.data, ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._reauth_entry = self.hass.config_entries.async_get_entry( - self.context["entry_id"] - ) + self._existing_entry_data = entry_data + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + self._existing_entry_data = self._get_reconfigure_entry().data return await self.async_step_user() + async def async_step_captcha( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show captcha form.""" + if user_input and user_input.get(CONF_CAPTCHA_TOKEN): + self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip() + return await self.async_step_user(self.data) + + return self.async_show_form( + step_id="captcha", + data_schema=CAPTCHA_SCHEMA, + description_placeholders={ + "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION]) + }, + ) + @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, + config_entry: ConfigEntry, ) -> BMWOptionsFlow: """Return a MyBMW option flow.""" - return BMWOptionsFlow(config_entry) + return BMWOptionsFlow() -class BMWOptionsFlow(config_entries.OptionsFlowWithConfigEntry): +class BMWOptionsFlow(OptionsFlow): """Handle a option flow for MyBMW.""" async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Manage the options.""" return await self.async_step_account_options() async def async_step_account_options( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: # Manually update & reload the config entry after options change. @@ -166,9 +243,13 @@ async def async_step_account_options( ) -class CannotConnect(exceptions.HomeAssistantError): +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" diff --git a/custom_components/bmw_connected_drive/const.py b/custom_components/bmw_connected_drive/const.py index 37225fc..750289e 100644 --- a/custom_components/bmw_connected_drive/const.py +++ b/custom_components/bmw_connected_drive/const.py @@ -1,17 +1,22 @@ """Const file for the MyBMW integration.""" + from homeassistant.const import UnitOfLength, UnitOfVolume DOMAIN = "bmw_connected_drive" -ATTRIBUTION = "Data provided by MyBMW" ATTR_DIRECTION = "direction" ATTR_VIN = "vin" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] +CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" CONF_GCID = "gcid" +CONF_CAPTCHA_TOKEN = "captcha_token" +CONF_CAPTCHA_URL = ( + "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html" +) DATA_HASS_CONFIG = "hass_config" @@ -21,3 +26,9 @@ "LITERS": UnitOfVolume.LITERS, "GALLONS": UnitOfVolume.GALLONS, } + +SCAN_INTERVALS = { + "china": 300, + "north_america": 600, + "rest_of_world": 300, +} diff --git a/custom_components/bmw_connected_drive/coordinator.py b/custom_components/bmw_connected_drive/coordinator.py index f635442..4f560d1 100644 --- a/custom_components/bmw_connected_drive/coordinator.py +++ b/custom_components/bmw_connected_drive/coordinator.py @@ -1,4 +1,5 @@ """Coordinator for BMW.""" + from __future__ import annotations from datetime import timedelta @@ -6,7 +7,12 @@ from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError from homeassistant.config_entries import ConfigEntry @@ -14,11 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context -from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS -DEFAULT_SCAN_INTERVAL_SECONDS = 300 -SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) _LOGGER = logging.getLogger(__name__) @@ -34,8 +39,7 @@ def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - # Force metric system as BMW API apparently only returns metric values now - use_metric_units=True, + verify=get_default_context(), ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry @@ -50,17 +54,29 @@ def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: hass, _LOGGER, name=f"{DOMAIN}-{entry.data['username']}", - update_interval=SCAN_INTERVAL, + update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), ) + # Default to false on init so _async_update_data logic works + self.last_update_success = False + async def _async_update_data(self) -> None: """Fetch data from BMW.""" old_refresh_token = self.account.refresh_token try: await self.account.get_vehicles() + except MyBMWCaptchaMissingError as err: + # If a captcha is required (user/password login flow), always trigger the reauth flow + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_captcha", + ) from err except MyBMWAuthError as err: - # Clear refresh token and trigger reauth + # Allow one retry interval before raising AuthFailed to avoid flaky API issues + if self.last_update_success: + raise UpdateFailed(err) from err + # Clear refresh token and trigger reauth if previous update failed as well self._update_config_entry_refresh_token(None) raise ConfigEntryAuthFailed(err) from err except (MyBMWAPIError, RequestError) as err: @@ -68,11 +84,6 @@ async def _async_update_data(self) -> None: if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) - _LOGGER.debug( - "bimmer_connected: refresh token %s > %s", - old_refresh_token, - self.account.refresh_token, - ) def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" diff --git a/custom_components/bmw_connected_drive/device_tracker.py b/custom_components/bmw_connected_drive/device_tracker.py index 12d2973..977fd53 100644 --- a/custom_components/bmw_connected_drive/device_tracker.py +++ b/custom_components/bmw_connected_drive/device_tracker.py @@ -1,4 +1,5 @@ """Device tracker for MyBMW vehicles.""" + from __future__ import annotations import logging @@ -6,25 +7,25 @@ from bimmer_connected.vehicle import MyBMWVehicle -from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWBaseEntity -from .const import ATTR_DIRECTION, DOMAIN +from . import BMWConfigEntry +from .const import ATTR_DIRECTION from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW tracker from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities: list[BMWDeviceTracker] = [] for vehicle in coordinator.account.vehicles: @@ -45,6 +46,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): """MyBMW device tracker.""" _attr_force_update = False + _attr_translation_key = "car" _attr_icon = "mdi:car" def __init__( @@ -61,7 +63,7 @@ def __init__( @property def extra_state_attributes(self) -> dict[str, Any]: """Return entity specific state attributes.""" - return {**self._attrs, ATTR_DIRECTION: self.vehicle.vehicle_location.heading} + return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading} @property def latitude(self) -> float | None: @@ -82,8 +84,3 @@ def longitude(self) -> float | None: and self.vehicle.vehicle_location.location else None ) - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS diff --git a/custom_components/bmw_connected_drive/diagnostics.py b/custom_components/bmw_connected_drive/diagnostics.py index c69d06d..ff3c6f2 100644 --- a/custom_components/bmw_connected_drive/diagnostics.py +++ b/custom_components/bmw_connected_drive/diagnostics.py @@ -1,4 +1,5 @@ """Diagnostics support for the BMW Connected Drive integration.""" + from __future__ import annotations from dataclasses import asdict @@ -7,18 +8,17 @@ from bimmer_connected.utils import MyBMWJSONEncoder -from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_REFRESH_TOKEN, DOMAIN +from . import BMWConfigEntry +from .const import CONF_REFRESH_TOKEN if TYPE_CHECKING: from bimmer_connected.vehicle import MyBMWVehicle - from .coordinator import BMWDataUpdateCoordinator TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN] TO_REDACT_DATA = [ @@ -46,10 +46,10 @@ def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: BMWConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) @@ -72,10 +72,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) diff --git a/custom_components/bmw_connected_drive/entity.py b/custom_components/bmw_connected_drive/entity.py new file mode 100644 index 0000000..8063121 --- /dev/null +++ b/custom_components/bmw_connected_drive/entity.py @@ -0,0 +1,40 @@ +"""Base for all BMW entities.""" + +from __future__ import annotations + +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + + +class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]): + """Common base for BMW entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + + self.vehicle = vehicle + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=vehicle.brand.name, + model=vehicle.name, + name=vehicle.name, + serial_number=vehicle.vin, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/custom_components/bmw_connected_drive/icons.json b/custom_components/bmw_connected_drive/icons.json new file mode 100644 index 0000000..fc30b87 --- /dev/null +++ b/custom_components/bmw_connected_drive/icons.json @@ -0,0 +1,102 @@ +{ + "entity": { + "binary_sensor": { + "lids": { + "default": "mdi:car-door-lock" + }, + "windows": { + "default": "mdi:car-door" + }, + "door_lock_state": { + "default": "mdi:car-key" + }, + "condition_based_services": { + "default": "mdi:wrench" + }, + "check_control_messages": { + "default": "mdi:car-tire-alert" + }, + "charging_status": { + "default": "mdi:ev-station" + }, + "connection_status": { + "default": "mdi:car-electric" + }, + "is_pre_entry_climatization_enabled": { + "default": "mdi:car-seat-heater" + } + }, + "button": { + "light_flash": { + "default": "mdi:car-light-alert" + }, + "sound_horn": { + "default": "mdi:bullhorn" + }, + "activate_air_conditioning": { + "default": "mdi:hvac" + }, + "deactivate_air_conditioning": { + "default": "mdi:hvac-off" + }, + "find_vehicle": { + "default": "mdi:crosshairs-question" + } + }, + "device_tracker": { + "car": { + "default": "mdi:car" + } + }, + "number": { + "target_soc": { + "default": "mdi:battery-charging-medium" + } + }, + "select": { + "ac_limit": { + "default": "mdi:current-ac" + }, + "charging_mode": { + "default": "mdi:vector-point-select" + } + }, + "sensor": { + "charging_status": { + "default": "mdi:ev-station" + }, + "charging_target": { + "default": "mdi:battery-charging-high" + }, + "mileage": { + "default": "mdi:speedometer" + }, + "remaining_range_total": { + "default": "mdi:map-marker-distance" + }, + "remaining_range_electric": { + "default": "mdi:map-marker-distance" + }, + "remaining_range_fuel": { + "default": "mdi:map-marker-distance" + }, + "remaining_fuel": { + "default": "mdi:gas-station" + }, + "remaining_fuel_percent": { + "default": "mdi:gas-station" + }, + "climate_status": { + "default": "mdi:fan" + } + }, + "switch": { + "climate": { + "default": "mdi:fan" + }, + "charging": { + "default": "mdi:ev-station" + } + } + } +} diff --git a/custom_components/bmw_connected_drive/lock.py b/custom_components/bmw_connected_drive/lock.py index d20ccd1..3dfc0b1 100644 --- a/custom_components/bmw_connected_drive/lock.py +++ b/custom_components/bmw_connected_drive/lock.py @@ -1,20 +1,22 @@ """Support for BMW car locks with BMW ConnectedDrive.""" + from __future__ import annotations import logging from typing import Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.doors_windows import LockState from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWBaseEntity -from .const import DOMAIN +from . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity DOOR_LOCK_STATE = "door_lock_state" _LOGGER = logging.getLogger(__name__) @@ -22,29 +24,22 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - - entities: list[BMWLock] = [] + coordinator = config_entry.runtime_data.coordinator - for vehicle in coordinator.account.vehicles: - if not coordinator.read_only: - entities.append( - BMWLock( - coordinator, - vehicle, - ) - ) - async_add_entities(entities) + if not coordinator.read_only: + async_add_entities( + BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles + ) class BMWLock(BMWBaseEntity, LockEntity): """Representation of a MyBMW vehicle lock.""" - _attr_name = "Lock" + _attr_translation_key = "lock" def __init__( self, @@ -55,7 +50,7 @@ def __init__( super().__init__(coordinator, vehicle) self._attr_unique_id = f"{vehicle.vin}-lock" - self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes + self.door_lock_state_available = vehicle.is_lsc_enabled async def async_lock(self, **kwargs: Any) -> None: """Lock the car.""" @@ -66,9 +61,16 @@ async def async_lock(self, **kwargs: Any) -> None: # update callback response self._attr_is_locked = True self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_lock() - - self.coordinator.async_update_listeners() + try: + await self.vehicle.remote_services.trigger_remote_door_lock() + except MyBMWAPIError as ex: + # Set the state to unknown if the command fails + self._attr_is_locked = None + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" @@ -79,16 +81,21 @@ async def async_unlock(self, **kwargs: Any) -> None: # update callback response self._attr_is_locked = False self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_unlock() - - self.coordinator.async_update_listeners() + try: + await self.vehicle.remote_services.trigger_remote_door_unlock() + except MyBMWAPIError as ex: + # Set the state to unknown if the command fails + self._attr_is_locked = None + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" _LOGGER.debug("Updating lock data of %s", self.vehicle.name) - # Set default attributes - self._attr_extra_state_attributes = self._attrs # Only update the HA state machine if the vehicle reliably reports its lock state if self.door_lock_state_available: @@ -96,8 +103,8 @@ def _handle_coordinator_update(self) -> None: LockState.LOCKED, LockState.SECURED, } - self._attr_extra_state_attributes[ - "door_lock_state" - ] = self.vehicle.doors_and_windows.door_lock_state.value + self._attr_extra_state_attributes = { + DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value + } super()._handle_coordinator_update() diff --git a/custom_components/bmw_connected_drive/manifest.json b/custom_components/bmw_connected_drive/manifest.json index 8c9cc91..3f7831c 100644 --- a/custom_components/bmw_connected_drive/manifest.json +++ b/custom_components/bmw_connected_drive/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "version": "2023.6.0b4", - "requirements": ["bimmer_connected==0.13.6"] + "version": "2024.12.0b3", + "requirements": ["bimmer-connected[china]==0.17.0"] } diff --git a/custom_components/bmw_connected_drive/notify.py b/custom_components/bmw_connected_drive/notify.py index 036d514..5652335 100644 --- a/custom_components/bmw_connected_drive/notify.py +++ b/custom_components/bmw_connected_drive/notify.py @@ -1,34 +1,39 @@ """Support for BMW notifications.""" + from __future__ import annotations import logging from typing import Any, cast +from bimmer_connected.models import MyBMWAPIError, PointOfInterest from bimmer_connected.vehicle import MyBMWVehicle +import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, BaseNotificationService, ) -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LOCATION, - ATTR_LONGITUDE, - ATTR_NAME, - CONF_ENTITY_ID, -) +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN -from .coordinator import BMWDataUpdateCoordinator +from . import DOMAIN, BMWConfigEntry -ATTR_LAT = "lat" ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] -ATTR_LON = "lon" -ATTR_SUBJECT = "subject" -ATTR_TEXT = "text" + +POI_SCHEMA = vol.Schema( + { + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional("street"): cv.string, + vol.Optional("city"): cv.string, + vol.Optional("postal_code"): cv.string, + vol.Optional("country"): cv.string, + } +) _LOGGER = logging.getLogger(__name__) @@ -39,12 +44,16 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> BMWNotificationService: """Get the BMW notification service.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry( (discovery_info or {})[CONF_ENTITY_ID] - ] + ) targets = {} - if not coordinator.read_only: + if ( + config_entry + and (coordinator := config_entry.runtime_data.coordinator) + and not coordinator.read_only + ): targets.update({v.name: v for v in coordinator.account.vehicles}) return BMWNotificationService(targets) @@ -65,29 +74,34 @@ def targets(self) -> dict[str, Any] | None: async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message or POI to the car.""" + + try: + # Verify data schema + poi_data = kwargs.get(ATTR_DATA) or {} + POI_SCHEMA(poi_data) + + # Create the POI object + poi = PointOfInterest( + lat=poi_data.pop(ATTR_LATITUDE), + lon=poi_data.pop(ATTR_LONGITUDE), + name=(message or None), + **poi_data, + ) + + except (vol.Invalid, TypeError, ValueError) as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_poi", + translation_placeholders={ + "poi_exception": str(ex), + }, + ) from ex + for vehicle in kwargs[ATTR_TARGET]: vehicle = cast(MyBMWVehicle, vehicle) _LOGGER.debug("Sending message to %s", vehicle.name) - # Extract params from data dict - data = kwargs.get(ATTR_DATA) - - # Check if message is a POI - if data is not None and ATTR_LOCATION in data: - location_dict = { - ATTR_LAT: data[ATTR_LOCATION][ATTR_LATITUDE], - ATTR_LON: data[ATTR_LOCATION][ATTR_LONGITUDE], - ATTR_NAME: message, - } - # Update dictionary with additional attributes if available - location_dict.update( - { - k: v - for k, v in data[ATTR_LOCATION].items() - if k in ATTR_LOCATION_ATTRIBUTES - } - ) - - await vehicle.remote_services.trigger_send_poi(location_dict) - else: - raise ValueError(f"'data.{ATTR_LOCATION}' is required.") + try: + await vehicle.remote_services.trigger_send_poi(poi) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/custom_components/bmw_connected_drive/number.py b/custom_components/bmw_connected_drive/number.py index 16db7e8..54519ff 100644 --- a/custom_components/bmw_connected_drive/number.py +++ b/custom_components/bmw_connected_drive/number.py @@ -14,38 +14,31 @@ 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 . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass -class BMWRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class BMWNumberEntityDescription(NumberEntityDescription): + """Describes BMW number entity.""" 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 | None = None # for HA<2023.6 + NUMBER_TYPES: list[BMWNumberEntityDescription] = [ BMWNumberEntityDescription( key="target_soc", - name="Target SoC", + translation_key="target_soc", device_class=NumberDeviceClass.BATTERY, is_available=lambda v: v.is_remote_set_target_soc_enabled, native_max_value=100.0, @@ -56,18 +49,17 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): 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, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW number from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities: list[BMWNumber] = [] diff --git a/custom_components/bmw_connected_drive/select.py b/custom_components/bmw_connected_drive/select.py index 0b20ed9..323768a 100644 --- a/custom_components/bmw_connected_drive/select.py +++ b/custom_components/bmw_connected_drive/select.py @@ -1,77 +1,72 @@ """Select 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 bimmer_connected.vehicle.charging_profile import ChargingMode from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWBaseEntity -from .const import DOMAIN +from . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass -class BMWRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class BMWSelectEntityDescription(SelectEntityDescription): + """Describes BMW sensor entity.""" 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] = { - "ac_limit": BMWSelectEntityDescription( +SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = ( + BMWSelectEntityDescription( key="ac_limit", - name="AC Charging Limit", + translation_key="ac_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] + 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( + BMWSelectEntityDescription( key="charging_mode", - name="Charging Mode", + translation_key="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] + options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # 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, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities: list[BMWSelect] = [] @@ -80,7 +75,7 @@ async def async_setup_entry( entities.extend( [ BMWSelect(coordinator, vehicle, description) - for description in SELECT_TYPES.values() + for description in SELECT_TYPES if description.is_available(vehicle) ] ) @@ -123,6 +118,9 @@ async def async_select_option(self, option: str) -> None: self.vehicle.vin, option, ) - await self.entity_description.remote_service(self.vehicle, option) + try: + await self.entity_description.remote_service(self.vehicle, option) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/custom_components/bmw_connected_drive/sensor.py b/custom_components/bmw_connected_drive/sensor.py index 314ff47..e24e2dd 100644 --- a/custom_components/bmw_connected_drive/sensor.py +++ b/custom_components/bmw_connected_drive/sensor.py @@ -1,168 +1,204 @@ """Support for reading vehicle status from MyBMW portal.""" + from __future__ import annotations 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 bimmer_connected.vehicle.climate import ClimateActivityState +from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + STATE_UNKNOWN, + UnitOfElectricCurrent, + UnitOfLength, + UnitOfPressure, + UnitOfVolume, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent 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 DOMAIN, UNIT_MAP +from . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_type: str | None = None - value: Callable = lambda x, y: x - - -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: dict[str, BMWSensorEntityDescription] = { - # --- Generic --- - "ac_current_limit": BMWSensorEntityDescription( - key="ac_current_limit", - name="AC current limit", - key_class="charging_profile", - unit_type=UnitOfElectricCurrent.AMPERE, - icon="mdi:current-ac", + is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled + + +TIRES = ["front_left", "front_right", "rear_left", "rear_right"] + +SENSOR_TYPES: list[BMWSensorEntityDescription] = [ + BMWSensorEntityDescription( + key="charging_profile.ac_current_limit", + translation_key="ac_current_limit", + 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, ), - "charging_start_time": BMWSensorEntityDescription( - key="charging_start_time", - name="Charging start time", - key_class="fuel_and_battery", + BMWSensorEntityDescription( + key="fuel_and_battery.charging_start_time", + translation_key="charging_start_time", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, + is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - "charging_end_time": BMWSensorEntityDescription( - key="charging_end_time", - name="Charging end time", - key_class="fuel_and_battery", + BMWSensorEntityDescription( + key="fuel_and_battery.charging_end_time", + translation_key="charging_end_time", device_class=SensorDeviceClass.TIMESTAMP, + is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - "charging_status": BMWSensorEntityDescription( - key="charging_status", - name="Charging status", - key_class="fuel_and_battery", - icon="mdi:ev-station", - value=lambda x, y: x.value, + BMWSensorEntityDescription( + key="fuel_and_battery.charging_status", + translation_key="charging_status", + device_class=SensorDeviceClass.ENUM, + options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN], + is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - "charging_target": BMWSensorEntityDescription( - key="charging_target", - name="Charging target", - key_class="fuel_and_battery", - icon="mdi:battery-charging-high", - unit_type=PERCENTAGE, + BMWSensorEntityDescription( + key="fuel_and_battery.charging_target", + translation_key="charging_target", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - "remaining_battery_percent": BMWSensorEntityDescription( - key="remaining_battery_percent", - name="Remaining battery percent", - key_class="fuel_and_battery", - unit_type=PERCENTAGE, + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_battery_percent", + translation_key="remaining_battery_percent", 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 --- - "mileage": BMWSensorEntityDescription( + BMWSensorEntityDescription( key="mileage", - name="Mileage", - icon="mdi:speedometer", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + translation_key="mileage", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + ), + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_range_total", + translation_key="remaining_range_total", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), - "remaining_range_total": BMWSensorEntityDescription( - key="remaining_range_total", - name="Remaining range total", - key_class="fuel_and_battery", - icon="mdi:map-marker-distance", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_range_electric", + translation_key="remaining_range_electric", + 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, ), - "remaining_range_electric": BMWSensorEntityDescription( - key="remaining_range_electric", - name="Remaining range electric", - key_class="fuel_and_battery", - icon="mdi:map-marker-distance", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_range_fuel", + translation_key="remaining_range_fuel", + 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, ), - "remaining_range_fuel": BMWSensorEntityDescription( - key="remaining_range_fuel", - name="Remaining range fuel", - key_class="fuel_and_battery", - icon="mdi:map-marker-distance", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_fuel", + translation_key="remaining_fuel", + device_class=SensorDeviceClass.VOLUME_STORAGE, + 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, ), - "remaining_fuel": BMWSensorEntityDescription( - key="remaining_fuel", - name="Remaining fuel", - key_class="fuel_and_battery", - icon="mdi:gas-station", - unit_type=VOLUME, - value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + BMWSensorEntityDescription( + key="fuel_and_battery.remaining_fuel_percent", + translation_key="remaining_fuel_percent", + 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, ), - "remaining_fuel_percent": BMWSensorEntityDescription( - key="remaining_fuel_percent", - name="Remaining fuel percent", - key_class="fuel_and_battery", - icon="mdi:gas-station", - unit_type=PERCENTAGE, + BMWSensorEntityDescription( + key="climate.activity", + translation_key="climate_status", + device_class=SensorDeviceClass.ENUM, + options=[ + s.value.lower() + for s in ClimateActivityState + if s != ClimateActivityState.UNKNOWN + ], + is_available=lambda v: v.is_remote_climate_stop_enabled, ), -} + *[ + BMWSensorEntityDescription( + key=f"tires.{tire}.current_pressure", + translation_key=f"{tire}_current_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.KPA, + suggested_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + is_available=lambda v: v.is_lsc_enabled and v.tires is not None, + ) + for tire in TIRES + ], + *[ + BMWSensorEntityDescription( + key=f"tires.{tire}.target_pressure", + translation_key=f"{tire}_target_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.KPA, + suggested_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + is_available=lambda v: v.is_lsc_enabled and v.tires is not None, + ) + for tire in TIRES + ], +] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW sensors from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator - entities: list[BMWSensor] = [] - - for vehicle in coordinator.account.vehicles: - entities.extend( - [ - BMWSensor(coordinator, vehicle, description) - for attribute_name in vehicle.available_attributes - if (description := SENSOR_TYPES.get(attribute_name)) - ] - ) + entities = [ + BMWSensor(coordinator, vehicle, description) + for vehicle in coordinator.account.vehicles + for description in SENSOR_TYPES + if description.is_available(vehicle) + ] async_add_entities(entities) @@ -183,27 +219,30 @@ def __init__( 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.""" _LOGGER.debug( "Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name ) - if self.entity_description.key_class is None: - state = getattr(self.vehicle, self.entity_description.key) - else: - state = getattr( - getattr(self.vehicle, self.entity_description.key_class), - self.entity_description.key, - ) - self._attr_native_value = cast( - StateType, self.entity_description.value(state, self.hass) - ) + + key_path = self.entity_description.key.split(".") + state = getattr(self.vehicle, key_path.pop(0)) + + for key in key_path: + state = getattr(state, key) + + # 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 = state super()._handle_coordinator_update() diff --git a/custom_components/bmw_connected_drive/strings.json b/custom_components/bmw_connected_drive/strings.json index 506175b..8078971 100644 --- a/custom_components/bmw_connected_drive/strings.json +++ b/custom_components/bmw_connected_drive/strings.json @@ -7,15 +7,28 @@ "password": "[%key:common::config_flow::data::password%]", "region": "ConnectedDrive Region" } + }, + "captcha": { + "title": "Are you a robot?", + "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.", + "data": { + "captcha_token": "Captcha token" + }, + "data_description": { + "captcha_token": "One-time token retrieved from the captcha challenge." + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_captcha": "Captcha validation missing" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "account_mismatch": "Username and region are not allowed to change" } }, "options": { @@ -26,5 +39,181 @@ } } } + }, + "entity": { + "binary_sensor": { + "lids": { + "name": "Lids" + }, + "windows": { + "name": "Windows" + }, + "door_lock_state": { + "name": "Door lock state" + }, + "condition_based_services": { + "name": "Condition based services" + }, + "check_control_messages": { + "name": "Check control messages" + }, + "charging_status": { + "name": "Charging status" + }, + "connection_status": { + "name": "Connection status" + }, + "is_pre_entry_climatization_enabled": { + "name": "Pre entry climatization" + } + }, + "button": { + "light_flash": { + "name": "Flash lights" + }, + "sound_horn": { + "name": "Sound horn" + }, + "activate_air_conditioning": { + "name": "Activate air conditioning" + }, + "find_vehicle": { + "name": "Find vehicle" + } + }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "target_soc": { + "name": "Target SoC" + } + }, + "select": { + "ac_limit": { + "name": "AC Charging Limit" + }, + "charging_mode": { + "name": "Charging Mode", + "state": { + "immediate_charging": "Immediate charging", + "delayed_charging": "Delayed charging", + "no_action": "No action" + } + } + }, + "sensor": { + "ac_current_limit": { + "name": "AC current limit" + }, + "charging_start_time": { + "name": "Charging start time" + }, + "charging_end_time": { + "name": "Charging end time" + }, + "charging_status": { + "name": "Charging status", + "state": { + "default": "Default", + "charging": "Charging", + "error": "Error", + "complete": "Complete", + "fully_charged": "Fully charged", + "finished_fully_charged": "Finished, fully charged", + "finished_not_full": "Finished, not full", + "invalid": "Invalid", + "not_charging": "Not charging", + "plugged_in": "Plugged in", + "waiting_for_charging": "Waiting for charging", + "target_reached": "Target reached" + } + }, + "charging_target": { + "name": "Charging target" + }, + "remaining_battery_percent": { + "name": "Remaining battery percent" + }, + "mileage": { + "name": "Mileage" + }, + "remaining_range_total": { + "name": "Remaining range total" + }, + "remaining_range_electric": { + "name": "Remaining range electric" + }, + "remaining_range_fuel": { + "name": "Remaining range fuel" + }, + "remaining_fuel": { + "name": "Remaining fuel" + }, + "remaining_fuel_percent": { + "name": "Remaining fuel percent" + }, + "climate_status": { + "name": "Climate status", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "inactive": "Inactive", + "standby": "Standby", + "ventilation": "Ventilation" + } + }, + "front_left_current_pressure": { + "name": "Front left tire pressure" + }, + "front_right_current_pressure": { + "name": "Front right tire pressure" + }, + "rear_left_current_pressure": { + "name": "Rear left tire pressure" + }, + "rear_right_current_pressure": { + "name": "Rear right tire pressure" + }, + "front_left_target_pressure": { + "name": "Front left target pressure" + }, + "front_right_target_pressure": { + "name": "Front right target pressure" + }, + "rear_left_target_pressure": { + "name": "Rear left target pressure" + }, + "rear_right_target_pressure": { + "name": "Rear right target pressure" + } + }, + "switch": { + "climate": { + "name": "Climate" + }, + "charging": { + "name": "Charging" + } + } + }, + "selector": { + "regions": { + "options": { + "china": "China", + "north_america": "North America", + "rest_of_world": "Rest of world" + } + } + }, + "exceptions": { + "invalid_poi": { + "message": "Invalid data for point of interest: {poi_exception}" + }, + "missing_captcha": { + "message": "Login requires captcha validation" + } } } diff --git a/custom_components/bmw_connected_drive/switch.py b/custom_components/bmw_connected_drive/switch.py index 41243ca..e8a02ef 100644 --- a/custom_components/bmw_connected_drive/switch.py +++ b/custom_components/bmw_connected_drive/switch.py @@ -10,31 +10,24 @@ from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -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 . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator +from .entity import BMWBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass -class BMWRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class BMWSwitchEntityDescription(SwitchEntityDescription): + """Describes BMW switch entity.""" value_fn: Callable[[MyBMWVehicle], bool] remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] - - -@dataclass -class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): - """Describes BMW switch entity.""" - is_available: Callable[[MyBMWVehicle], bool] = lambda _: False dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None @@ -51,32 +44,30 @@ class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ BMWSwitchEntityDescription( key="climate", - name="Climate", + translation_key="climate", is_available=lambda v: v.is_remote_climate_stop_enabled, value_fn=lambda v: v.climate.is_climate_on, remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), - icon="mdi:fan", ), BMWSwitchEntityDescription( key="charging", - name="Charging", + translation_key="charging", is_available=lambda v: v.is_remote_charge_stop_enabled, value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, remote_service_on=lambda v: v.remote_services.trigger_charge_start(), remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), - icon="mdi:ev-station", ), ] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW switch from config entry.""" - coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator entities: list[BMWSwitch] = []