From 8e32fdbedf9b664ad05079563298e80a3bed34f9 Mon Sep 17 00:00:00 2001 From: rikroe Date: Sun, 27 Mar 2022 21:38:56 +0200 Subject: [PATCH] Add DataUpdateCoordinator (home-assistant/core/pull/67003) --- .../bmw_connected_drive/__init__.py | 301 ++++-------------- .../bmw_connected_drive/binary_sensor.py | 35 +- .../bmw_connected_drive/button.py | 45 +-- .../bmw_connected_drive/config_flow.py | 4 - .../bmw_connected_drive/const.py | 13 +- .../bmw_connected_drive/coordinator.py | 131 ++++++++ .../bmw_connected_drive/device_tracker.py | 31 +- custom_components/bmw_connected_drive/lock.py | 41 ++- .../bmw_connected_drive/notify.py | 34 +- .../bmw_connected_drive/sensor.py | 26 +- 10 files changed, 294 insertions(+), 367 deletions(-) create mode 100644 custom_components/bmw_connected_drive/coordinator.py diff --git a/custom_components/bmw_connected_drive/__init__.py b/custom_components/bmw_connected_drive/__init__.py index 400ab50..9ac53d6 100644 --- a/custom_components/bmw_connected_drive/__init__.py +++ b/custom_components/bmw_connected_drive/__init__.py @@ -1,59 +1,39 @@ """Reads vehicle status from BMW connected drive portal.""" from __future__ import annotations -from collections.abc import Callable -import logging -from typing import Any, cast +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 SOURCE_IMPORT, ConfigEntry +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.helpers import device_registry, discovery +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 homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + ATTR_VIN, ATTRIBUTION, - CONF_ACCOUNT, - CONF_ALLOWED_REGIONS, CONF_READ_ONLY, - DATA_ENTRIES, DATA_HASS_CONFIG, + DOMAIN, + SERVICE_MAP, ) +from .coordinator import BMWDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "bmw_connected_drive" -ATTR_VIN = "vin" - -ACCOUNT_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_REGION): vol.In(CONF_ALLOWED_REGIONS), - vol.Optional(CONF_READ_ONLY): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: ACCOUNT_SCHEMA}}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) SERVICE_SCHEMA = vol.Schema( vol.Any( @@ -74,34 +54,16 @@ Platform.NOTIFY, Platform.SENSOR, ] -UPDATE_INTERVAL = 5 # in minutes SERVICE_UPDATE_STATE = "update_state" -_SERVICE_MAP = { - "light_flash": "trigger_remote_light_flash", - "sound_horn": "trigger_remote_horn", - "activate_air_conditioning": "trigger_remote_air_conditioning", - "deactivate_air_conditioning": "trigger_remote_air_conditioning_stop", - "find_vehicle": "trigger_remote_vehicle_finder", -} - -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.""" + # Store full yaml config in data for platform.NOTIFY hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][DATA_HASS_CONFIG] = config - if DOMAIN in config: - for entry_config in config[DOMAIN].values(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config - ) - ) - return True @@ -121,40 +83,35 @@ 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) + # 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() - hass.data[DOMAIN][DATA_ENTRIES][entry.entry_id] = { - CONF_ACCOUNT: account, - UNDO_UPDATE_LISTENER: undo_listener, - } + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator # Service to manually trigger updates for all accounts. - hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, _async_update_all) - - await _async_update_all() + # Deprecated and will be removed in 2022.4 when only buttons are supported. + hass.services.async_register( + DOMAIN, SERVICE_UPDATE_STATE, coordinator.async_request_refresh + ) + # Add all other services + # Deprecated and will be removed in 2022.4 when only buttons are supported. + for service in SERVICE_MAP: + hass.services.async_register( + DOMAIN, service, coordinator.async_execute_service, schema=SERVICE_SCHEMA + ) + # Set up all platforms except notify hass.config_entries.async_setup_platforms( entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) @@ -166,11 +123,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 @@ -181,199 +141,48 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Only remove services if it is the last account and not read only - if ( - len(hass.data[DOMAIN][DATA_ENTRIES]) == 1 - and not hass.data[DOMAIN][DATA_ENTRIES][entry.entry_id][CONF_ACCOUNT].read_only + # Deprecated and will be removed in 2022.4 when only buttons are supported. + if len(hass.data[DOMAIN]) == 1 and not getattr( + hass.data[DOMAIN][entry.entry_id], CONF_READ_ONLY ): - services = list(_SERVICE_MAP) + [SERVICE_UPDATE_STATE] + services = list(SERVICE_MAP) + [SERVICE_UPDATE_STATE] for service in services: hass.services.async_remove(DOMAIN, service) - 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 - ) - - def execute_service(call: ServiceCall) -> None: - """Execute a service for a vehicle.""" - _LOGGER.warning( - "BMW Connected Drive services are deprecated. Please migrate to the dedicated button entities. " - "See https://www.home-assistant.io/integrations/bmw_connected_drive/#buttons for details" - ) - - vin: str | None = call.data.get(ATTR_VIN) - device_id: str | None = call.data.get(CONF_DEVICE_ID) - - vehicle: ConnectedDriveVehicle | None = None - - if not vin and device_id: - # If vin is None, device_id must be set (given by SERVICE_SCHEMA) - if not (device := device_registry.async_get(hass).async_get(device_id)): - _LOGGER.error("Could not find a device for id: %s", device_id) - return - vin = next(iter(device.identifiers))[1] - else: - vin = cast(str, vin) - - # Double check for read_only accounts as another account could create the services - for entry_data in [ - e - for e in hass.data[DOMAIN][DATA_ENTRIES].values() - if not e[CONF_ACCOUNT].read_only - ]: - account: ConnectedDriveAccount = entry_data[CONF_ACCOUNT].account - if vehicle := account.get_vehicle(vin): - break - if not vehicle: - _LOGGER.error("Could not find a vehicle for VIN %s", vin) - return - function_name = _SERVICE_MAP[call.service] - function_call = getattr(vehicle.remote_services, function_name) - function_call() - - if call.service in [ - "find_vehicle", - "activate_air_conditioning", - "deactivate_air_conditioning", - ]: - cd_account.update() - - if not read_only: - # register the remote services - for service in _SERVICE_MAP: - hass.services.register( - DOMAIN, service, execute_service, schema=SERVICE_SCHEMA - ) - - # 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) diff --git a/custom_components/bmw_connected_drive/binary_sensor.py b/custom_components/bmw_connected_drive/binary_sensor.py index 8110b53..646e9e2 100644 --- a/custom_components/bmw_connected_drive/binary_sensor.py +++ b/custom_components/bmw_connected_drive/binary_sensor.py @@ -24,12 +24,9 @@ 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__) @@ -216,17 +213,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 +231,26 @@ 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 + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + _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( + return self.entity_description.value_fn( vehicle_state, result, self._unit_system ) - self._attr_extra_state_attributes = result diff --git a/custom_components/bmw_connected_drive/button.py b/custom_components/bmw_connected_drive/button.py index 72d66d7..78185d0 100644 --- a/custom_components/bmw_connected_drive/button.py +++ b/custom_components/bmw_connected_drive/button.py @@ -1,8 +1,9 @@ """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 +from typing import TYPE_CHECKING from bimmer_connected.remote_services import RemoteServiceStatus from bimmer_connected.vehicle import ConnectedDriveVehicle @@ -12,12 +13,11 @@ 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 @dataclass @@ -28,7 +28,7 @@ class BMWButtonEntityDescription(ButtonEntityDescription): remote_function: Callable[ [ConnectedDriveVehicle], RemoteServiceStatus ] | None = None - account_function: Callable[[BMWConnectedDriveAccount], None] | None = None + account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( @@ -66,7 +66,7 @@ class BMWButtonEntityDescription(ButtonEntityDescription): 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,22 @@ 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( + self.entity_description.remote_function(self.vehicle) + ) elif self.entity_description.account_function: - self.entity_description.account_function(self._account) + await self.entity_description.account_function(self.coordinator) diff --git a/custom_components/bmw_connected_drive/config_flow.py b/custom_components/bmw_connected_drive/config_flow.py index 3b07830..fec2539 100644 --- a/custom_components/bmw_connected_drive/config_flow.py +++ b/custom_components/bmw_connected_drive/config_flow.py @@ -74,10 +74,6 @@ async def async_step_user( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import.""" - return await self.async_step_user(user_input) - @staticmethod @callback def async_get_options_flow( diff --git a/custom_components/bmw_connected_drive/const.py b/custom_components/bmw_connected_drive/const.py index 0f79a16..b25200b 100644 --- a/custom_components/bmw_connected_drive/const.py +++ b/custom_components/bmw_connected_drive/const.py @@ -6,18 +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_USE_LOCATION = "use_location" - CONF_ACCOUNT = "account" DATA_HASS_CONFIG = "hass_config" -DATA_ENTRIES = "entries" UNIT_MAP = { "KILOMETERS": LENGTH_KILOMETERS, @@ -25,3 +24,11 @@ "LITERS": VOLUME_LITERS, "GALLONS": VOLUME_GALLONS, } + +SERVICE_MAP = { + "light_flash": "trigger_remote_light_flash", + "sound_horn": "trigger_remote_horn", + "activate_air_conditioning": "trigger_remote_air_conditioning", + "deactivate_air_conditioning": "trigger_remote_air_conditioning_stop", + "find_vehicle": "trigger_remote_vehicle_finder", +} diff --git a/custom_components/bmw_connected_drive/coordinator.py b/custom_components/bmw_connected_drive/coordinator.py new file mode 100644 index 0000000..77045d1 --- /dev/null +++ b/custom_components/bmw_connected_drive/coordinator.py @@ -0,0 +1,131 @@ +"""Coordinator for BMW.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any, cast + +import async_timeout +from bimmer_connected.account import ConnectedDriveAccount +from bimmer_connected.country_selector import get_region_from_name +from bimmer_connected.vehicle import ConnectedDriveVehicle + +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ATTR_VIN, DOMAIN, SERVICE_MAP + +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, + ) + except OSError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + # Deprecated and will be removed in 2022.4 when only buttons are supported. + # Required to call `async_request_refresh` from a service call with arguments. + async def async_request_refresh(self, *args: Any, **kwargs: Any) -> None: + """Request a refresh. + + Refresh will wait a bit to see if it can batch them. + Allows to be called from a service call. + """ + await super().async_request_refresh() + + # Deprecated and will be removed in 2022.4 when only buttons are supported. + async def async_execute_service(self, call: ServiceCall) -> None: + """Execute a service for a vehicle.""" + _LOGGER.warning( + "BMW Connected Drive services are deprecated. Please migrate to the dedicated button entities. " + "See https://www.home-assistant.io/integrations/bmw_connected_drive/#buttons for details" + ) + + vin: str | None = call.data.get(ATTR_VIN) + device_id: str | None = call.data.get(CONF_DEVICE_ID) + + coordinator: BMWDataUpdateCoordinator + vehicle: ConnectedDriveVehicle | None = None + + if not vin and device_id: + # If vin is None, device_id must be set (given by SERVICE_SCHEMA) + if not ( + device := device_registry.async_get(self.hass).async_get(device_id) + ): + _LOGGER.error("Could not find a device for id: %s", device_id) + return + vin = next(iter(device.identifiers))[1] + else: + vin = cast(str, vin) + + # Search through all coordinators for vehicle + # Double check for read_only accounts as another account could create the services + entry_coordinator: BMWDataUpdateCoordinator + for entry_coordinator in self.hass.data[DOMAIN].values(): + if ( + isinstance(entry_coordinator, DataUpdateCoordinator) + and not entry_coordinator.read_only + ): + account: ConnectedDriveAccount = entry_coordinator.account + if vehicle := account.get_vehicle(vin): + coordinator = entry_coordinator + break + if not vehicle: + _LOGGER.error("Could not find a vehicle for VIN %s", vin) + return + function_name = SERVICE_MAP[call.service] + function_call = getattr(vehicle.remote_services, function_name) + await self.hass.async_add_executor_job(function_call) + + if call.service in [ + "find_vehicle", + "activate_air_conditioning", + "deactivate_air_conditioning", + ]: + await coordinator.async_request_refresh() diff --git a/custom_components/bmw_connected_drive/device_tracker.py b/custom_components/bmw_connected_drive/device_tracker.py index 9062921..0f2ccb2 100644 --- a/custom_components/bmw_connected_drive/device_tracker.py +++ b/custom_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,11 +47,11 @@ 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 @@ -79,12 +74,12 @@ def source_type(self) -> Literal["gps"]: def update(self) -> None: """Update state of the device tracker.""" - _LOGGER.debug("Updating device tracker of %s", self._vehicle.name) + _LOGGER.debug("Updating device tracker of %s", self.vehicle.name) state_attrs = self._attrs - state_attrs[ATTR_DIRECTION] = self._vehicle.status.gps_heading + 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 + self.vehicle.status.gps_position + if self.vehicle.is_vehicle_tracking_enabled else None ) diff --git a/custom_components/bmw_connected_drive/lock.py b/custom_components/bmw_connected_drive/lock.py index 7153901..495762e 100644 --- a/custom_components/bmw_connected_drive/lock.py +++ b/custom_components/bmw_connected_drive/lock.py @@ -10,12 +10,9 @@ 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 +from .coordinator import BMWDataUpdateCoordinator DOOR_LOCK_STATE = "door_lock_state" _LOGGER = logging.getLogger(__name__) @@ -27,16 +24,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] - if not account.read_only: - entities = [ - BMWLock(account, vehicle, "lock", "BMW lock") - for vehicle in account.account.vehicles - ] - async_add_entities(entities, True) + entities: list[BMWLock] = [] + + 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 +39,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,28 +55,28 @@ def __init__( def lock(self, **kwargs: Any) -> None: """Lock the car.""" - _LOGGER.debug("%s: locking doors", self._vehicle.name) + _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() + 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) + _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() + 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 + "Updating lock data for '%s' of %s", self._attribute, self.vehicle.name ) - vehicle_state = self._vehicle.status + vehicle_state = self.vehicle.status if not self.door_lock_state_available: self._attr_is_locked = None else: diff --git a/custom_components/bmw_connected_drive/notify.py b/custom_components/bmw_connected_drive/notify.py index 2db25aa..42e9c83 100644 --- a/custom_components/bmw_connected_drive/notify.py +++ b/custom_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/custom_components/bmw_connected_drive/sensor.py b/custom_components/bmw_connected_drive/sensor.py index f21c1b8..806d51b 100644 --- a/custom_components/bmw_connected_drive/sensor.py +++ b/custom_components/bmw_connected_drive/sensor.py @@ -27,12 +27,9 @@ 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__) @@ -134,21 +131,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): @@ -158,13 +154,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}" @@ -178,5 +174,5 @@ def __init__( @property def native_value(self) -> StateType: """Return the state.""" - state = getattr(self._vehicle.status, self.entity_description.key) + state = getattr(self.vehicle.status, self.entity_description.key) return cast(StateType, self.entity_description.value(state, self.hass))