From 80653463bfcbe29410c95f77f3ae0ceba3c067e8 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 21 Apr 2022 10:13:09 +0200 Subject: [PATCH] Add DataUpdateCoordinator to bmw_connected_drive (#67003) Co-authored-by: rikroe --- .coveragerc | 1 + .../bmw_connected_drive/__init__.py | 200 ++++-------------- .../bmw_connected_drive/binary_sensor.py | 181 +++++++--------- .../components/bmw_connected_drive/button.py | 76 ++++--- .../components/bmw_connected_drive/const.py | 4 +- .../bmw_connected_drive/coordinator.py | 74 +++++++ .../bmw_connected_drive/device_tracker.py | 53 +++-- .../components/bmw_connected_drive/lock.py | 92 ++++---- .../components/bmw_connected_drive/notify.py | 34 +-- .../components/bmw_connected_drive/sensor.py | 42 ++-- 10 files changed, 345 insertions(+), 412 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/coordinator.py diff --git a/.coveragerc b/.coveragerc index 8f45cbadf768ad..3decd8d86c0f7c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -118,6 +118,7 @@ omit = homeassistant/components/bmw_connected_drive/__init__.py homeassistant/components/bmw_connected_drive/binary_sensor.py homeassistant/components/bmw_connected_drive/button.py + homeassistant/components/bmw_connected_drive/coordinator.py homeassistant/components/bmw_connected_drive/device_tracker.py homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index ae1ac790c367c9..a6e0d663871e70 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,47 +1,30 @@ """Reads vehicle status from BMW connected drive portal.""" from __future__ import annotations -from collections.abc import Callable -import logging from typing import Any -from bimmer_connected.account import ConnectedDriveAccount -from bimmer_connected.country_selector import get_region_from_name from bimmer_connected.vehicle import ConnectedDriveVehicle import voluptuous as vol -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, + CONF_ENTITY_ID, CONF_NAME, CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util - -from .const import ( - ATTRIBUTION, - CONF_ACCOUNT, - CONF_READ_ONLY, - DATA_ENTRIES, - DATA_HASS_CONFIG, -) - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -DOMAIN = "bmw_connected_drive" -ATTR_VIN = "vin" +from .const import ATTR_VIN, ATTRIBUTION, CONF_READ_ONLY, DATA_HASS_CONFIG, DOMAIN +from .coordinator import BMWDataUpdateCoordinator CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -64,12 +47,9 @@ Platform.NOTIFY, Platform.SENSOR, ] -UPDATE_INTERVAL = 5 # in minutes SERVICE_UPDATE_STATE = "update_state" -UNDO_UPDATE_LISTENER = "undo_update_listener" - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the BMW Connected Drive component from configuration.yaml.""" @@ -96,37 +76,23 @@ def _async_migrate_options_from_data_if_missing( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BMW Connected Drive from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(DATA_ENTRIES, {}) _async_migrate_options_from_data_if_missing(hass, entry) - try: - account = await hass.async_add_executor_job( - setup_account, entry, hass, entry.data[CONF_USERNAME] - ) - except OSError as ex: - raise ConfigEntryNotReady from ex - - async def _async_update_all(service_call: ServiceCall | None = None) -> None: - """Update all BMW accounts.""" - await hass.async_add_executor_job(_update_all) - - def _update_all() -> None: - """Update all BMW accounts.""" - for entry in hass.data[DOMAIN][DATA_ENTRIES].copy().values(): - entry[CONF_ACCOUNT].update() - - # Add update listener for config entry changes (options) - undo_listener = entry.add_update_listener(update_listener) - - hass.data[DOMAIN][DATA_ENTRIES][entry.entry_id] = { - CONF_ACCOUNT: account, - UNDO_UPDATE_LISTENER: undo_listener, - } + # Set up one data coordinator per account/config entry + coordinator = BMWDataUpdateCoordinator( + hass, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + region=entry.data[CONF_REGION], + read_only=entry.options[CONF_READ_ONLY], + ) + await coordinator.async_config_entry_first_refresh() - await _async_update_all() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + # Set up all platforms except notify hass.config_entries.async_setup_platforms( entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) @@ -138,11 +104,14 @@ def _update_all() -> None: hass, Platform.NOTIFY, DOMAIN, - {CONF_NAME: DOMAIN}, + {CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id}, hass.data[DOMAIN][DATA_HASS_CONFIG], ) ) + # Add event listener for option flow changes + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -152,140 +121,45 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) - for vehicle in hass.data[DOMAIN][DATA_ENTRIES][entry.entry_id][ - CONF_ACCOUNT - ].account.vehicles: - hass.services.async_remove(NOTIFY_DOMAIN, slugify(f"{DOMAIN}_{vehicle.name}")) - if unload_ok: - hass.data[DOMAIN][DATA_ENTRIES][entry.entry_id][UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN][DATA_ENTRIES].pop(entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) -def setup_account( - entry: ConfigEntry, hass: HomeAssistant, name: str -) -> BMWConnectedDriveAccount: - """Set up a new BMWConnectedDriveAccount based on the config.""" - username: str = entry.data[CONF_USERNAME] - password: str = entry.data[CONF_PASSWORD] - region: str = entry.data[CONF_REGION] - read_only: bool = entry.options[CONF_READ_ONLY] - - _LOGGER.debug("Adding new account %s", name) - - pos = (hass.config.latitude, hass.config.longitude) - cd_account = BMWConnectedDriveAccount( - username, password, region, name, read_only, *pos - ) - - # update every UPDATE_INTERVAL minutes, starting now - # this should even out the load on the servers - now = dt_util.utcnow() - track_utc_time_change( - hass, - cd_account.update, - minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), - second=now.second, - ) - - # Initialize - cd_account.update() - - return cd_account - - -class BMWConnectedDriveAccount: - """Representation of a BMW vehicle.""" - - def __init__( - self, - username: str, - password: str, - region_str: str, - name: str, - read_only: bool, - lat: float | None = None, - lon: float | None = None, - ) -> None: - """Initialize account.""" - region = get_region_from_name(region_str) - - self.read_only = read_only - self.account = ConnectedDriveAccount(username, password, region) - self.name = name - self._update_listeners: list[Callable[[], None]] = [] - - # Set observer position once for older cars to be in range for - # GPS position (pre-7/2014, <2km) and get new data from API - if lat and lon: - self.account.set_observer_position(lat, lon) - self.account.update_vehicle_states() - - def update(self, *_: Any) -> None: - """Update the state of all vehicles. - - Notify all listeners about the update. - """ - _LOGGER.debug( - "Updating vehicle state for account %s, notifying %d listeners", - self.name, - len(self._update_listeners), - ) - try: - self.account.update_vehicle_states() - for listener in self._update_listeners: - listener() - except OSError as exception: - _LOGGER.error( - "Could not connect to the BMW Connected Drive portal. " - "The vehicle state could not be updated" - ) - _LOGGER.exception(exception) - - def add_update_listener(self, listener: Callable[[], None]) -> None: - """Add a listener for update notifications.""" - self._update_listeners.append(listener) - - -class BMWConnectedDriveBaseEntity(Entity): +class BMWConnectedDriveBaseEntity(CoordinatorEntity, Entity): """Common base for BMW entities.""" - _attr_should_poll = False + coordinator: BMWDataUpdateCoordinator _attr_attribution = ATTRIBUTION def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, ) -> None: - """Initialize sensor.""" - self._account = account - self._vehicle = vehicle + """Initialize entity.""" + super().__init__(coordinator) + + self.vehicle = vehicle + self._attrs: dict[str, Any] = { - "car": self._vehicle.name, - "vin": self._vehicle.vin, + "car": self.vehicle.name, + "vin": self.vehicle.vin, } self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, vehicle.vin)}, + identifiers={(DOMAIN, self.vehicle.vin)}, manufacturer=vehicle.brand.name, model=vehicle.name, name=f"{vehicle.brand.name} {vehicle.name}", ) - def update_callback(self) -> None: - """Schedule a state update.""" - self.schedule_update_ha_state(True) - async def async_added_to_hass(self) -> None: - """Add callback after being added to hass. - - Show latest data after startup. - """ - self._account.add_update_listener(self.update_callback) + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 8110b535716b25..cae70f6de4bbf3 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -20,100 +20,37 @@ BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import UnitSystem -from . import ( - DOMAIN as BMW_DOMAIN, - BMWConnectedDriveAccount, - BMWConnectedDriveBaseEntity, -) -from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP +from . import BMWConnectedDriveBaseEntity +from .const import DOMAIN, UNIT_MAP +from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -def _are_doors_closed( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class opening: On means open, Off means closed - _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) - for lid in vehicle_state.lids: - extra_attributes[lid.name] = lid.state.value - return not vehicle_state.all_lids_closed - - -def _are_windows_closed( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class opening: On means open, Off means closed - for window in vehicle_state.windows: - extra_attributes[window.name] = window.state.value - return not vehicle_state.all_windows_closed - - -def _are_doors_locked( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class lock: On means unlocked, Off means locked - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - extra_attributes["door_lock_state"] = vehicle_state.door_lock_state.value - extra_attributes["last_update_reason"] = vehicle_state.last_update_reason - return vehicle_state.door_lock_state not in {LockState.LOCKED, LockState.SECURED} - - -def _are_parking_lights_on( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class light: On means light detected, Off means no light - extra_attributes["lights_parking"] = vehicle_state.parking_lights.value - return cast(bool, vehicle_state.are_parking_lights_on) - - -def _are_problems_detected( - vehicle_state: VehicleStatus, - extra_attributes: dict[str, Any], - unit_system: UnitSystem, -) -> bool: - # device class problem: On means problem detected, Off means no problem +def _condition_based_services( + vehicle_state: VehicleStatus, unit_system: UnitSystem +) -> dict[str, Any]: + extra_attributes = {} for report in vehicle_state.condition_based_services: extra_attributes.update(_format_cbs_report(report, unit_system)) - return not vehicle_state.are_all_cbs_ok + return extra_attributes -def _check_control_messages( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class problem: On means problem detected, Off means no problem - check_control_messages = vehicle_state.check_control_messages - has_check_control_messages = vehicle_state.has_check_control_messages - if has_check_control_messages: - cbs_list = [message.description_short for message in check_control_messages] +def _check_control_messages(vehicle_state: VehicleStatus) -> dict[str, Any]: + extra_attributes: dict[str, Any] = {} + if vehicle_state.has_check_control_messages: + cbs_list = [ + message.description_short + for message in vehicle_state.check_control_messages + ] extra_attributes["check_control_messages"] = cbs_list else: extra_attributes["check_control_messages"] = "OK" - return cast(bool, vehicle_state.has_check_control_messages) - - -def _is_vehicle_charging( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class power: On means power detected, Off means no power - extra_attributes["charging_status"] = vehicle_state.charging_status.value - extra_attributes[ - "last_charging_end_result" - ] = vehicle_state.last_charging_end_result - return cast(bool, vehicle_state.charging_status == ChargingState.CHARGING) - - -def _is_vehicle_plugged_in( - vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any -) -> bool: - # device class plug: On means device is plugged in, - # Off means device is unplugged - extra_attributes["connection_status"] = vehicle_state.connection_status - return cast(str, vehicle_state.connection_status) == "CONNECTED" + return extra_attributes def _format_cbs_report( @@ -139,7 +76,7 @@ def _format_cbs_report( class BMWRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[VehicleStatus, dict[str, Any], UnitSystem], bool] + value_fn: Callable[[VehicleStatus], bool] @dataclass @@ -148,6 +85,8 @@ class BMWBinarySensorEntityDescription( ): """Describes BMW binary_sensor entity.""" + attr_fn: Callable[[VehicleStatus, UnitSystem], dict[str, Any]] | None = None + SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( @@ -155,42 +94,59 @@ class BMWBinarySensorEntityDescription( name="Doors", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door-lock", - value_fn=_are_doors_closed, + # device class opening: On means open, Off means closed + value_fn=lambda s: not s.all_lids_closed, + attr_fn=lambda s, u: {lid.name: lid.state.value for lid in s.lids}, ), BMWBinarySensorEntityDescription( key="windows", name="Windows", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door", - value_fn=_are_windows_closed, + # device class opening: On means open, Off means closed + value_fn=lambda s: not s.all_windows_closed, + attr_fn=lambda s, u: {window.name: window.state.value for window in s.windows}, ), BMWBinarySensorEntityDescription( key="door_lock_state", name="Door lock state", device_class=BinarySensorDeviceClass.LOCK, icon="mdi:car-key", - value_fn=_are_doors_locked, + # device class lock: On means unlocked, Off means locked + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + value_fn=lambda s: s.door_lock_state + not in {LockState.LOCKED, LockState.SECURED}, + attr_fn=lambda s, u: { + "door_lock_state": s.door_lock_state.value, + "last_update_reason": s.last_update_reason, + }, ), BMWBinarySensorEntityDescription( key="lights_parking", name="Parking lights", device_class=BinarySensorDeviceClass.LIGHT, icon="mdi:car-parking-lights", - value_fn=_are_parking_lights_on, + # device class light: On means light detected, Off means no light + value_fn=lambda s: cast(bool, s.are_parking_lights_on), + attr_fn=lambda s, u: {"lights_parking": s.parking_lights.value}, ), BMWBinarySensorEntityDescription( key="condition_based_services", name="Condition based services", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:wrench", - value_fn=_are_problems_detected, + # device class problem: On means problem detected, Off means no problem + value_fn=lambda s: not s.are_all_cbs_ok, + attr_fn=_condition_based_services, ), BMWBinarySensorEntityDescription( key="check_control_messages", name="Control messages", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:car-tire-alert", - value_fn=_check_control_messages, + # device class problem: On means problem detected, Off means no problem + value_fn=lambda s: cast(bool, s.has_check_control_messages), + attr_fn=lambda s, u: _check_control_messages(s), ), # electric BMWBinarySensorEntityDescription( @@ -198,14 +154,20 @@ class BMWBinarySensorEntityDescription( name="Charging status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, icon="mdi:ev-station", - value_fn=_is_vehicle_charging, + # device class power: On means power detected, Off means no power + value_fn=lambda s: cast(bool, s.charging_status == ChargingState.CHARGING), + attr_fn=lambda s, u: { + "charging_status": s.charging_status.value, + "last_charging_end_result": s.last_charging_end_result, + }, ), BMWBinarySensorEntityDescription( key="connection_status", name="Connection status", device_class=BinarySensorDeviceClass.PLUG, icon="mdi:car-electric", - value_fn=_is_vehicle_plugged_in, + value_fn=lambda s: cast(str, s.connection_status) == "CONNECTED", + attr_fn=lambda s, u: {"connection_status": s.connection_status}, ), ) @@ -216,17 +178,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW ConnectedDrive binary sensors from config entry.""" - account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ - config_entry.entry_id - ][CONF_ACCOUNT] + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [ - BMWConnectedDriveSensor(account, vehicle, description, hass.config.units) - for vehicle in account.account.vehicles + BMWConnectedDriveSensor(coordinator, vehicle, description, hass.config.units) + for vehicle in coordinator.account.vehicles for description in SENSOR_TYPES if description.key in vehicle.available_attributes ] - async_add_entities(entities, True) + async_add_entities(entities) class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): @@ -236,26 +196,35 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, description: BMWBinarySensorEntityDescription, unit_system: UnitSystem, ) -> None: """Initialize sensor.""" - super().__init__(account, vehicle) + super().__init__(coordinator, vehicle) self.entity_description = description self._unit_system = unit_system self._attr_name = f"{vehicle.name} {description.key}" self._attr_unique_id = f"{vehicle.vin}-{description.key}" - def update(self) -> None: - """Read new state data from the library.""" - _LOGGER.debug("Updating binary sensors of %s", self._vehicle.name) - vehicle_state = self._vehicle.status - result = self._attrs.copy() - - self._attr_is_on = self.entity_description.value_fn( - vehicle_state, result, self._unit_system + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating binary sensor '%s' of %s", + self.entity_description.key, + self.vehicle.name, ) - self._attr_extra_state_attributes = result + vehicle_state = self.vehicle.status + + self._attr_is_on = self.entity_description.value_fn(vehicle_state) + + if self.entity_description.attr_fn: + self._attr_extra_state_attributes = dict( + self._attrs, + **self.entity_description.attr_fn(vehicle_state, self._unit_system), + ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 72d66d746abd85..254fbebfdacfa5 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -1,10 +1,11 @@ """Support for BMW connected drive button entities.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING -from bimmer_connected.remote_services import RemoteServiceStatus from bimmer_connected.vehicle import ConnectedDriveVehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -12,12 +13,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - DOMAIN as BMW_DOMAIN, - BMWConnectedDriveAccount, - BMWConnectedDriveBaseEntity, -) -from .const import CONF_ACCOUNT, DATA_ENTRIES +from . import BMWConnectedDriveBaseEntity +from .const import DOMAIN + +if TYPE_CHECKING: + from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -25,10 +27,8 @@ class BMWButtonEntityDescription(ButtonEntityDescription): """Class describing BMW button entities.""" enabled_when_read_only: bool = False - remote_function: Callable[ - [ConnectedDriveVehicle], RemoteServiceStatus - ] | None = None - account_function: Callable[[BMWConnectedDriveAccount], None] | None = None + remote_function: str | None = None + account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( @@ -36,37 +36,37 @@ class BMWButtonEntityDescription(ButtonEntityDescription): key="light_flash", icon="mdi:car-light-alert", name="Flash Lights", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), + remote_function="trigger_remote_light_flash", ), BMWButtonEntityDescription( key="sound_horn", icon="mdi:bullhorn", name="Sound Horn", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), + remote_function="trigger_remote_horn", ), BMWButtonEntityDescription( key="activate_air_conditioning", icon="mdi:hvac", name="Activate Air Conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), + remote_function="trigger_remote_air_conditioning", ), BMWButtonEntityDescription( key="deactivate_air_conditioning", icon="mdi:hvac-off", name="Deactivate Air Conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), + remote_function="trigger_remote_air_conditioning_stop", ), BMWButtonEntityDescription( key="find_vehicle", icon="mdi:crosshairs-question", name="Find Vehicle", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), + remote_function="trigger_remote_vehicle_finder", ), BMWButtonEntityDescription( key="refresh", icon="mdi:refresh", name="Refresh from cloud", - account_function=lambda account: account.update(), + account_function=lambda coordinator: coordinator.async_request_refresh(), enabled_when_read_only=True, ), ) @@ -78,18 +78,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW ConnectedDrive buttons from config entry.""" - account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ - config_entry.entry_id - ][CONF_ACCOUNT] + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities: list[BMWButton] = [] - for vehicle in account.account.vehicles: + for vehicle in coordinator.account.vehicles: entities.extend( [ - BMWButton(account, vehicle, description) + BMWButton(coordinator, vehicle, description) for description in BUTTON_TYPES - if not account.read_only - or (account.read_only and description.enabled_when_read_only) + if not coordinator.read_only + or (coordinator.read_only and description.enabled_when_read_only) ] ) @@ -103,20 +102,35 @@ class BMWButton(BMWConnectedDriveBaseEntity, ButtonEntity): def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, description: BMWButtonEntityDescription, ) -> None: """Initialize BMW vehicle sensor.""" - super().__init__(account, vehicle) + super().__init__(coordinator, vehicle) self.entity_description = description self._attr_name = f"{vehicle.name} {description.name}" self._attr_unique_id = f"{vehicle.vin}-{description.key}" - def press(self) -> None: - """Process the button press.""" + async def async_press(self) -> None: + """Press the button.""" if self.entity_description.remote_function: - self.entity_description.remote_function(self._vehicle) + await self.hass.async_add_executor_job( + getattr( + self.vehicle.remote_services, + self.entity_description.remote_function, + ) + ) elif self.entity_description.account_function: - self.entity_description.account_function(self._account) + _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) + + # Always update HA states after a button was executed. + # BMW remote services that change the vehicle's state update the local object + # when executing the service, so only the HA state machine needs further updates. + self.coordinator.notify_listeners() diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index f7908910803e1a..a2082c0bedea60 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -6,17 +6,17 @@ VOLUME_LITERS, ) +DOMAIN = "bmw_connected_drive" ATTRIBUTION = "Data provided by BMW Connected Drive" ATTR_DIRECTION = "direction" +ATTR_VIN = "vin" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" - CONF_ACCOUNT = "account" DATA_HASS_CONFIG = "hass_config" -DATA_ENTRIES = "entries" UNIT_MAP = { "KILOMETERS": LENGTH_KILOMETERS, diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py new file mode 100644 index 00000000000000..a02b4bdd27c5f9 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for BMW.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +from bimmer_connected.account import ConnectedDriveAccount +from bimmer_connected.country_selector import get_region_from_name + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=300) +_LOGGER = logging.getLogger(__name__) + + +class BMWDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching BMW data.""" + + account: ConnectedDriveAccount + + def __init__( + self, + hass: HomeAssistant, + *, + username: str, + password: str, + region: str, + read_only: bool = False, + ) -> None: + """Initialize account-wide BMW data updater.""" + # Storing username & password in coordinator is needed until a new library version + # that does not do blocking IO on init. + self._username = username + self._password = password + self._region = get_region_from_name(region) + + self.account = None + self.read_only = read_only + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{username}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> None: + """Fetch data from BMW.""" + try: + async with async_timeout.timeout(15): + if isinstance(self.account, ConnectedDriveAccount): + # pylint: disable=protected-access + await self.hass.async_add_executor_job(self.account._get_vehicles) + else: + self.account = await self.hass.async_add_executor_job( + ConnectedDriveAccount, + self._username, + self._password, + self._region, + ) + self.account.set_observer_position( + self.hass.config.latitude, self.hass.config.longitude + ) + except OSError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + def notify_listeners(self) -> None: + """Notify all listeners to refresh HA state machine.""" + for update_callback in self._listeners: + update_callback() diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 90629216641e72..b1fa429f5b943d 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -12,12 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - DOMAIN as BMW_DOMAIN, - BMWConnectedDriveAccount, - BMWConnectedDriveBaseEntity, -) -from .const import ATTR_DIRECTION, CONF_ACCOUNT, DATA_ENTRIES +from . import BMWConnectedDriveBaseEntity +from .const import ATTR_DIRECTION, DOMAIN +from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,20 +25,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW ConnectedDrive tracker from config entry.""" - account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ - config_entry.entry_id - ][CONF_ACCOUNT] + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BMWDeviceTracker] = [] - for vehicle in account.account.vehicles: - entities.append(BMWDeviceTracker(account, vehicle)) + for vehicle in coordinator.account.vehicles: + entities.append(BMWDeviceTracker(coordinator, vehicle)) if not vehicle.is_vehicle_tracking_enabled: _LOGGER.info( "Tracking is (currently) disabled for vehicle %s (%s), defaulting to unknown", vehicle.name, vehicle.vin, ) - async_add_entities(entities, True) + async_add_entities(entities) class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): @@ -52,39 +47,39 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, ) -> None: """Initialize the Tracker.""" - super().__init__(account, vehicle) + super().__init__(coordinator, vehicle) self._attr_unique_id = vehicle.vin - self._location = pos if (pos := vehicle.status.gps_position) else None self._attr_name = vehicle.name + @property + def extra_state_attributes(self) -> dict: + """Return entity specific state attributes.""" + return dict(self._attrs, **{ATTR_DIRECTION: self.vehicle.status.gps_heading}) + @property def latitude(self) -> float | None: """Return latitude value of the device.""" - return self._location[0] if self._location else None + return ( + self.vehicle.status.gps_position[0] + if self.vehicle.is_vehicle_tracking_enabled + else None + ) @property def longitude(self) -> float | None: """Return longitude value of the device.""" - return self._location[1] if self._location else None + return ( + self.vehicle.status.gps_position[1] + if self.vehicle.is_vehicle_tracking_enabled + else None + ) @property def source_type(self) -> Literal["gps"]: """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS - - def update(self) -> None: - """Update state of the device tracker.""" - _LOGGER.debug("Updating device tracker of %s", self._vehicle.name) - state_attrs = self._attrs - state_attrs[ATTR_DIRECTION] = self._vehicle.status.gps_heading - self._attr_extra_state_attributes = state_attrs - self._location = ( - self._vehicle.status.gps_position - if self._vehicle.is_vehicle_tracking_enabled - else None - ) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 71539019f82fa3..a395c80ebcc9d9 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -1,4 +1,6 @@ """Support for BMW car locks with BMW ConnectedDrive.""" +from __future__ import annotations + import logging from typing import Any @@ -7,15 +9,12 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - DOMAIN as BMW_DOMAIN, - BMWConnectedDriveAccount, - BMWConnectedDriveBaseEntity, -) -from .const import CONF_ACCOUNT, DATA_ENTRIES +from . import BMWConnectedDriveBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator DOOR_LOCK_STATE = "door_lock_state" _LOGGER = logging.getLogger(__name__) @@ -27,16 +26,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW ConnectedDrive binary sensors from config entry.""" - account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ - config_entry.entry_id - ][CONF_ACCOUNT] + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWLock] = [] - if not account.read_only: - entities = [ - BMWLock(account, vehicle, "lock", "BMW lock") - for vehicle in account.account.vehicles - ] - async_add_entities(entities, True) + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.append(BMWLock(coordinator, vehicle, "lock", "BMW lock")) + async_add_entities(entities) class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): @@ -44,13 +41,13 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, attribute: str, sensor_name: str, ) -> None: """Initialize the lock.""" - super().__init__(account, vehicle) + super().__init__(coordinator, vehicle) self._attribute = attribute self._attr_name = f"{vehicle.name} {attribute}" @@ -60,38 +57,43 @@ def __init__( def lock(self, **kwargs: Any) -> None: """Lock the car.""" - _LOGGER.debug("%s: locking doors", self._vehicle.name) - # Optimistic state set here because it takes some time before the - # update callback response - self._attr_is_locked = True - self.schedule_update_ha_state() - self._vehicle.remote_services.trigger_remote_door_lock() + _LOGGER.debug("%s: locking doors", self.vehicle.name) + # Only update the HA state machine if the vehicle reliably reports its lock state + if self.door_lock_state_available: + # Optimistic state set here because it takes some time before the + # update callback response + self._attr_is_locked = True + self.schedule_update_ha_state() + self.vehicle.remote_services.trigger_remote_door_lock() def unlock(self, **kwargs: Any) -> None: """Unlock the car.""" - _LOGGER.debug("%s: unlocking doors", self._vehicle.name) - # Optimistic state set here because it takes some time before the - # update callback response - self._attr_is_locked = False - self.schedule_update_ha_state() - self._vehicle.remote_services.trigger_remote_door_unlock() - - def update(self) -> None: - """Update state of the lock.""" - _LOGGER.debug( - "Updating lock data for '%s' of %s", self._attribute, self._vehicle.name - ) - vehicle_state = self._vehicle.status - if not self.door_lock_state_available: - self._attr_is_locked = None - else: + _LOGGER.debug("%s: unlocking doors", self.vehicle.name) + # Only update the HA state machine if the vehicle reliably reports its lock state + if self.door_lock_state_available: + # Optimistic state set here because it takes some time before the + # update callback response + self._attr_is_locked = False + self.schedule_update_ha_state() + self.vehicle.remote_services.trigger_remote_door_unlock() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Updating lock data of %s", self.vehicle.name) + # Only update the HA state machine if the vehicle reliably reports its lock state + if self.door_lock_state_available: + vehicle_state = self.vehicle.status self._attr_is_locked = vehicle_state.door_lock_state in { LockState.LOCKED, LockState.SECURED, } + self._attr_extra_state_attributes = dict( + self._attrs, + **{ + "door_lock_state": vehicle_state.door_lock_state.value, + "last_update_reason": vehicle_state.last_update_reason, + }, + ) - result = self._attrs.copy() - if self.door_lock_state_available: - result["door_lock_state"] = vehicle_state.door_lock_state.value - result["last_update_reason"] = vehicle_state.last_update_reason - self._attr_extra_state_attributes = result + super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 2db25aaa592bc9..42e9c834459621 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -11,12 +11,18 @@ ATTR_TARGET, BaseNotificationService, ) -from homeassistant.const import ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, ATTR_NAME +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LOCATION, + ATTR_LONGITUDE, + ATTR_NAME, + CONF_ENTITY_ID, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveAccount -from .const import CONF_ACCOUNT, DATA_ENTRIES +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator ATTR_LAT = "lat" ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] @@ -33,26 +39,22 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> BMWNotificationService: """Get the BMW notification service.""" - accounts: list[BMWConnectedDriveAccount] = [ - e[CONF_ACCOUNT] for e in hass.data[BMW_DOMAIN][DATA_ENTRIES].values() + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][ + (discovery_info or {})[CONF_ENTITY_ID] ] - _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts])) - svc = BMWNotificationService() - svc.setup(accounts) - return svc + + targets = {} + if not coordinator.read_only: + targets.update({v.name: v for v in coordinator.account.vehicles}) + return BMWNotificationService(targets) class BMWNotificationService(BaseNotificationService): """Send Notifications to BMW.""" - def __init__(self) -> None: + def __init__(self, targets: dict[str, ConnectedDriveVehicle]) -> None: """Set up the notification service.""" - self.targets: dict[str, ConnectedDriveVehicle] = {} - - def setup(self, accounts: list[BMWConnectedDriveAccount]) -> None: - """Get the BMW vehicle(s) for the account(s).""" - for account in accounts: - self.targets.update({v.name: v for v in account.account.vehicles}) + self.targets: dict[str, ConnectedDriveVehicle] = targets def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message or POI to the car.""" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 8b21a10a39f43f..4a928870eb2292 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -22,17 +22,14 @@ VOLUME_GALLONS, VOLUME_LITERS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import UnitSystem -from . import ( - DOMAIN as BMW_DOMAIN, - BMWConnectedDriveAccount, - BMWConnectedDriveBaseEntity, -) -from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP +from . import BMWConnectedDriveBaseEntity +from .const import DOMAIN, UNIT_MAP +from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -135,21 +132,20 @@ async def async_setup_entry( ) -> None: """Set up the BMW ConnectedDrive sensors from config entry.""" unit_system = hass.config.units - account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ - config_entry.entry_id - ][CONF_ACCOUNT] + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities: list[BMWConnectedDriveSensor] = [] - for vehicle in account.account.vehicles: + for vehicle in coordinator.account.vehicles: entities.extend( [ - BMWConnectedDriveSensor(account, vehicle, description, unit_system) + BMWConnectedDriveSensor(coordinator, vehicle, description, unit_system) for attribute_name in vehicle.available_attributes if (description := SENSOR_TYPES.get(attribute_name)) ] ) - async_add_entities(entities, True) + async_add_entities(entities) class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): @@ -159,13 +155,13 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): def __init__( self, - account: BMWConnectedDriveAccount, + coordinator: BMWDataUpdateCoordinator, vehicle: ConnectedDriveVehicle, description: BMWSensorEntityDescription, unit_system: UnitSystem, ) -> None: """Initialize BMW vehicle sensor.""" - super().__init__(account, vehicle) + super().__init__(coordinator, vehicle) self.entity_description = description self._attr_name = f"{vehicle.name} {description.key}" @@ -176,8 +172,14 @@ def __init__( else: self._attr_native_unit_of_measurement = description.unit_metric - @property - def native_value(self) -> StateType: - """Return the state.""" - state = getattr(self._vehicle.status, self.entity_description.key) - return cast(StateType, self.entity_description.value(state, self.hass)) + @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 + ) + state = getattr(self.vehicle.status, self.entity_description.key) + self._attr_native_value = cast( + StateType, self.entity_description.value(state, self.hass) + ) + super()._handle_coordinator_update()