From e5f31665b104dee42e2ec2fc282b3c75e3b40ed5 Mon Sep 17 00:00:00 2001
From: rikroe <42204099+rikroe@users.noreply.github.com>
Date: Tue, 29 Dec 2020 11:06:12 +0100
Subject: [PATCH] Add Config Flow to bmw_connected_drive (#39585)

* Add Config Flow to bmw_connected_drive

* Fix checks for bmw_connected_drive

* Adjust code as requested

* Clean .coveragerc after merge

* Use references for config flow

* Fix execute_service check against allowed accounts

* Adjust translation as username can be email or phone no

* Add BMWConnectedDriveBaseEntity mixin, remove unnecessary type casts

* Use BaseEntity correctly, fix pylint error

* Bump bimmer_connected to 0.7.13

* Adjustments for review

* Fix pylint

* Fix loading notify, move vin to entity attrs

* Remove vin from device registry

* Remove commented-out code

* Show tracker warning only if vehicle (currently) doesn't support location

* Remove unnecessary return values & other small adjustments

* Move original hass_config to own domain in hass.data

* Move entries to separate dict in hass.data

* Remove invalid_auth exception handling & test as it cannot happen

Co-authored-by: rikroe <rikroe@users.noreply.github.com>
---
 .coveragerc                                   |   7 +-
 .../bmw_connected_drive/__init__.py           | 266 +++++++++++++++---
 .../bmw_connected_drive/binary_sensor.py      |  87 +++---
 .../bmw_connected_drive/config_flow.py        | 119 ++++++++
 .../components/bmw_connected_drive/const.py   |  10 +
 .../bmw_connected_drive/device_tracker.py     | 106 ++++---
 .../components/bmw_connected_drive/lock.py    |  64 ++---
 .../bmw_connected_drive/manifest.json         |   3 +-
 .../components/bmw_connected_drive/notify.py  |   3 +-
 .../components/bmw_connected_drive/sensor.py  |  69 ++---
 .../bmw_connected_drive/strings.json          |  30 ++
 .../bmw_connected_drive/translations/en.json  |  31 ++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_test_all.txt                     |   3 +
 .../bmw_connected_drive/__init__.py           |   1 +
 .../bmw_connected_drive/test_config_flow.py   | 153 ++++++++++
 16 files changed, 736 insertions(+), 217 deletions(-)
 create mode 100644 homeassistant/components/bmw_connected_drive/config_flow.py
 create mode 100644 homeassistant/components/bmw_connected_drive/strings.json
 create mode 100644 homeassistant/components/bmw_connected_drive/translations/en.json
 create mode 100644 tests/components/bmw_connected_drive/__init__.py
 create mode 100644 tests/components/bmw_connected_drive/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index c16b6ecb986..9f2fdc80716 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -100,7 +100,12 @@ omit =
     homeassistant/components/bme280/sensor.py
     homeassistant/components/bme680/sensor.py
     homeassistant/components/bmp280/sensor.py
-    homeassistant/components/bmw_connected_drive/*
+    homeassistant/components/bmw_connected_drive/__init__.py
+    homeassistant/components/bmw_connected_drive/binary_sensor.py
+    homeassistant/components/bmw_connected_drive/device_tracker.py
+    homeassistant/components/bmw_connected_drive/lock.py
+    homeassistant/components/bmw_connected_drive/notify.py
+    homeassistant/components/bmw_connected_drive/sensor.py
     homeassistant/components/braviatv/__init__.py
     homeassistant/components/braviatv/const.py
     homeassistant/components/braviatv/media_player.py
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index c72d1ce40fe..e9f6a0d7f6f 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -1,29 +1,50 @@
 """Reads vehicle status from BMW connected drive portal."""
+import asyncio
 import logging
 
 from bimmer_connected.account import ConnectedDriveAccount
 from bimmer_connected.country_selector import get_region_from_name
 import voluptuous as vol
 
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import (
+    ATTR_ATTRIBUTION,
+    CONF_NAME,
+    CONF_PASSWORD,
+    CONF_USERNAME,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
 from homeassistant.helpers import discovery
 import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
 from homeassistant.helpers.event import track_utc_time_change
+from homeassistant.util import slugify
 import homeassistant.util.dt as dt_util
 
+from .const import (
+    ATTRIBUTION,
+    CONF_ACCOUNT,
+    CONF_ALLOWED_REGIONS,
+    CONF_READ_ONLY,
+    CONF_REGION,
+    CONF_USE_LOCATION,
+    DATA_ENTRIES,
+    DATA_HASS_CONFIG,
+)
+
 _LOGGER = logging.getLogger(__name__)
 
 DOMAIN = "bmw_connected_drive"
-CONF_REGION = "region"
-CONF_READ_ONLY = "read_only"
 ATTR_VIN = "vin"
 
 ACCOUNT_SCHEMA = vol.Schema(
     {
         vol.Required(CONF_USERNAME): cv.string,
         vol.Required(CONF_PASSWORD): cv.string,
-        vol.Required(CONF_REGION): vol.Any("north_america", "china", "rest_of_world"),
-        vol.Optional(CONF_READ_ONLY, default=False): cv.boolean,
+        vol.Required(CONF_REGION): vol.In(CONF_ALLOWED_REGIONS),
+        vol.Optional(CONF_READ_ONLY): cv.boolean,
     }
 )
 
@@ -31,8 +52,12 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: ACCOUNT_SCHEMA}}, extra=vol.ALLO
 
 SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string})
 
+DEFAULT_OPTIONS = {
+    CONF_READ_ONLY: False,
+    CONF_USE_LOCATION: False,
+}
 
-BMW_COMPONENTS = ["binary_sensor", "device_tracker", "lock", "notify", "sensor"]
+BMW_PLATFORMS = ["binary_sensor", "device_tracker", "lock", "notify", "sensor"]
 UPDATE_INTERVAL = 5  # in minutes
 
 SERVICE_UPDATE_STATE = "update_state"
@@ -44,49 +69,162 @@ _SERVICE_MAP = {
     "find_vehicle": "trigger_remote_vehicle_finder",
 }
 
+UNDO_UPDATE_LISTENER = "undo_update_listener"
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+    """Set up the BMW Connected Drive component from configuration.yaml."""
+    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
+
+
+@callback
+def _async_migrate_options_from_data_if_missing(hass, entry):
+    data = dict(entry.data)
+    options = dict(entry.options)
+
+    if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS):
+        options = dict(DEFAULT_OPTIONS, **options)
+        options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False)
 
-def setup(hass, config: dict):
-    """Set up the BMW connected drive components."""
-    accounts = []
-    for name, account_config in config[DOMAIN].items():
-        accounts.append(setup_account(account_config, hass, name))
+        hass.config_entries.async_update_entry(entry, data=data, options=options)
 
-    hass.data[DOMAIN] = accounts
 
-    def _update_all(call) -> None:
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """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=None):
         """Update all BMW accounts."""
-        for cd_account in hass.data[DOMAIN]:
-            cd_account.update()
+        await hass.async_add_executor_job(_update_all)
+
+    def _update_all() -> None:
+        """Update all BMW accounts."""
+        for entry in hass.data[DOMAIN][DATA_ENTRIES].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,
+    }
 
     # Service to manually trigger updates for all accounts.
-    hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all)
+    hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, _async_update_all)
+
+    await _async_update_all()
 
-    _update_all(None)
+    for platform in BMW_PLATFORMS:
+        if platform != NOTIFY_DOMAIN:
+            hass.async_create_task(
+                hass.config_entries.async_forward_entry_setup(entry, platform)
+            )
 
-    for component in BMW_COMPONENTS:
-        discovery.load_platform(hass, component, DOMAIN, {}, config)
+    # set up notify platform, no entry support for notify component yet,
+    # have to use discovery to load platform.
+    hass.async_create_task(
+        discovery.async_load_platform(
+            hass,
+            NOTIFY_DOMAIN,
+            DOMAIN,
+            {CONF_NAME: DOMAIN},
+            hass.data[DOMAIN][DATA_HASS_CONFIG],
+        )
+    )
 
     return True
 
 
-def setup_account(account_config: dict, hass, name: str) -> "BMWConnectedDriveAccount":
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """Unload a config entry."""
+    unload_ok = all(
+        await asyncio.gather(
+            *[
+                hass.config_entries.async_forward_entry_unload(entry, component)
+                for component in BMW_PLATFORMS
+                if component != NOTIFY_DOMAIN
+            ]
+        )
+    )
+
+    # 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
+    ):
+        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)
+
+    return unload_ok
+
+
+async def update_listener(hass, config_entry):
+    """Handle options update."""
+    await hass.config_entries.async_reload(config_entry.entry_id)
+
+
+def setup_account(entry: ConfigEntry, hass, name: str) -> "BMWConnectedDriveAccount":
     """Set up a new BMWConnectedDriveAccount based on the config."""
-    username = account_config[CONF_USERNAME]
-    password = account_config[CONF_PASSWORD]
-    region = account_config[CONF_REGION]
-    read_only = account_config[CONF_READ_ONLY]
+    username = entry.data[CONF_USERNAME]
+    password = entry.data[CONF_PASSWORD]
+    region = entry.data[CONF_REGION]
+    read_only = entry.options[CONF_READ_ONLY]
+    use_location = entry.options[CONF_USE_LOCATION]
 
     _LOGGER.debug("Adding new account %s", name)
-    cd_account = BMWConnectedDriveAccount(username, password, region, name, read_only)
 
-    def execute_service(call):
-        """Execute a service for a vehicle.
+    pos = (
+        (hass.config.latitude, hass.config.longitude) if use_location else (None, None)
+    )
+    cd_account = BMWConnectedDriveAccount(
+        username, password, region, name, read_only, *pos
+    )
 
-        This must be a member function as we need access to the cd_account
-        object here.
-        """
+    def execute_service(call):
+        """Execute a service for a vehicle."""
         vin = call.data[ATTR_VIN]
-        vehicle = cd_account.account.get_vehicle(vin)
+        vehicle = None
+        # 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
+        ]:
+            vehicle = entry_data[CONF_ACCOUNT].account.get_vehicle(vin)
+            if vehicle:
+                break
         if not vehicle:
             _LOGGER.error("Could not find a vehicle for VIN %s", vin)
             return
@@ -111,6 +249,9 @@ def setup_account(account_config: dict, hass, name: str) -> "BMWConnectedDriveAc
         second=now.second,
     )
 
+    # Initialize
+    cd_account.update()
+
     return cd_account
 
 
@@ -118,7 +259,14 @@ class BMWConnectedDriveAccount:
     """Representation of a BMW vehicle."""
 
     def __init__(
-        self, username: str, password: str, region_str: str, name: str, read_only
+        self,
+        username: str,
+        password: str,
+        region_str: str,
+        name: str,
+        read_only: bool,
+        lat=None,
+        lon=None,
     ) -> None:
         """Initialize account."""
         region = get_region_from_name(region_str)
@@ -128,6 +276,12 @@ class BMWConnectedDriveAccount:
         self.name = name
         self._update_listeners = []
 
+        # 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, *_):
         """Update the state of all vehicles.
 
@@ -152,3 +306,51 @@ class BMWConnectedDriveAccount:
     def add_update_listener(self, listener):
         """Add a listener for update notifications."""
         self._update_listeners.append(listener)
+
+
+class BMWConnectedDriveBaseEntity(Entity):
+    """Common base for BMW entities."""
+
+    def __init__(self, account, vehicle):
+        """Initialize sensor."""
+        self._account = account
+        self._vehicle = vehicle
+        self._attrs = {
+            "car": self._vehicle.name,
+            "vin": self._vehicle.vin,
+            ATTR_ATTRIBUTION: ATTRIBUTION,
+        }
+
+    @property
+    def device_info(self) -> dict:
+        """Return info for device registry."""
+        return {
+            "identifiers": {(DOMAIN, self._vehicle.vin)},
+            "name": f'{self._vehicle.attributes.get("brand")} {self._vehicle.name}',
+            "model": self._vehicle.name,
+            "manufacturer": self._vehicle.attributes.get("brand"),
+        }
+
+    @property
+    def device_state_attributes(self):
+        """Return the state attributes of the sensor."""
+        return self._attrs
+
+    @property
+    def should_poll(self):
+        """Do not poll this class.
+
+        Updates are triggered from BMWConnectedDriveAccount.
+        """
+        return False
+
+    def update_callback(self):
+        """Schedule a state update."""
+        self.schedule_update_ha_state(True)
+
+    async def async_added_to_hass(self):
+        """Add callback after being added to hass.
+
+        Show latest data after startup.
+        """
+        self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index 31ef2dacf3a..cad5426d548 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -9,10 +9,10 @@ from homeassistant.components.binary_sensor import (
     DEVICE_CLASS_PROBLEM,
     BinarySensorEntity,
 )
-from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_KILOMETERS
+from homeassistant.const import LENGTH_KILOMETERS
 
-from . import DOMAIN as BMW_DOMAIN
-from .const import ATTRIBUTION
+from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity
+from .const import CONF_ACCOUNT, DATA_ENTRIES
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -41,41 +41,40 @@ SENSOR_TYPES_ELEC = {
 SENSOR_TYPES_ELEC.update(SENSOR_TYPES)
 
 
-def setup_platform(hass, config, add_entities, discovery_info=None):
-    """Set up the BMW sensors."""
-    accounts = hass.data[BMW_DOMAIN]
-    _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts]))
-    devices = []
-    for account in accounts:
-        for vehicle in account.account.vehicles:
-            if vehicle.has_hv_battery:
-                _LOGGER.debug("BMW with a high voltage battery")
-                for key, value in sorted(SENSOR_TYPES_ELEC.items()):
-                    if key in vehicle.available_attributes:
-                        device = BMWConnectedDriveSensor(
-                            account, vehicle, key, value[0], value[1], value[2]
-                        )
-                        devices.append(device)
-            elif vehicle.has_internal_combustion_engine:
-                _LOGGER.debug("BMW with an internal combustion engine")
-                for key, value in sorted(SENSOR_TYPES.items()):
-                    if key in vehicle.available_attributes:
-                        device = BMWConnectedDriveSensor(
-                            account, vehicle, key, value[0], value[1], value[2]
-                        )
-                        devices.append(device)
-    add_entities(devices, True)
-
-
-class BMWConnectedDriveSensor(BinarySensorEntity):
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the BMW ConnectedDrive binary sensors from config entry."""
+    account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT]
+    entities = []
+
+    for vehicle in account.account.vehicles:
+        if vehicle.has_hv_battery:
+            _LOGGER.debug("BMW with a high voltage battery")
+            for key, value in sorted(SENSOR_TYPES_ELEC.items()):
+                if key in vehicle.available_attributes:
+                    device = BMWConnectedDriveSensor(
+                        account, vehicle, key, value[0], value[1], value[2]
+                    )
+                    entities.append(device)
+        elif vehicle.has_internal_combustion_engine:
+            _LOGGER.debug("BMW with an internal combustion engine")
+            for key, value in sorted(SENSOR_TYPES.items()):
+                if key in vehicle.available_attributes:
+                    device = BMWConnectedDriveSensor(
+                        account, vehicle, key, value[0], value[1], value[2]
+                    )
+                    entities.append(device)
+    async_add_entities(entities, True)
+
+
+class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity):
     """Representation of a BMW vehicle binary sensor."""
 
     def __init__(
         self, account, vehicle, attribute: str, sensor_name, device_class, icon
     ):
         """Initialize sensor."""
-        self._account = account
-        self._vehicle = vehicle
+        super().__init__(account, vehicle)
+
         self._attribute = attribute
         self._name = f"{self._vehicle.name} {self._attribute}"
         self._unique_id = f"{self._vehicle.vin}-{self._attribute}"
@@ -84,14 +83,6 @@ class BMWConnectedDriveSensor(BinarySensorEntity):
         self._icon = icon
         self._state = None
 
-    @property
-    def should_poll(self) -> bool:
-        """Return False.
-
-        Data update is triggered from BMWConnectedDriveEntity.
-        """
-        return False
-
     @property
     def unique_id(self):
         """Return the unique ID of the binary sensor."""
@@ -121,10 +112,7 @@ class BMWConnectedDriveSensor(BinarySensorEntity):
     def device_state_attributes(self):
         """Return the state attributes of the binary sensor."""
         vehicle_state = self._vehicle.state
-        result = {
-            "car": self._vehicle.name,
-            ATTR_ATTRIBUTION: ATTRIBUTION,
-        }
+        result = self._attrs.copy()
 
         if self._attribute == "lids":
             for lid in vehicle_state.lids:
@@ -205,14 +193,3 @@ class BMWConnectedDriveSensor(BinarySensorEntity):
                 f"{service_type} distance"
             ] = f"{distance} {self.hass.config.units.length_unit}"
         return result
-
-    def update_callback(self):
-        """Schedule a state update."""
-        self.schedule_update_ha_state(True)
-
-    async def async_added_to_hass(self):
-        """Add callback after being added to hass.
-
-        Show latest data after startup.
-        """
-        self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
new file mode 100644
index 00000000000..a6081d5ccc1
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -0,0 +1,119 @@
+"""Config flow for BMW ConnectedDrive integration."""
+import logging
+
+from bimmer_connected.account import ConnectedDriveAccount
+from bimmer_connected.country_selector import get_region_from_name
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME
+from homeassistant.core import callback
+
+from . import DOMAIN  # pylint: disable=unused-import
+from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REGION, CONF_USE_LOCATION
+
+_LOGGER = logging.getLogger(__name__)
+
+
+DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_USERNAME): str,
+        vol.Required(CONF_PASSWORD): str,
+        vol.Required(CONF_REGION): vol.In(CONF_ALLOWED_REGIONS),
+    }
+)
+
+
+async def validate_input(hass: core.HomeAssistant, data):
+    """Validate the user input allows us to connect.
+
+    Data has the keys from DATA_SCHEMA with values provided by the user.
+    """
+    try:
+        await hass.async_add_executor_job(
+            ConnectedDriveAccount,
+            data[CONF_USERNAME],
+            data[CONF_PASSWORD],
+            get_region_from_name(data[CONF_REGION]),
+        )
+    except OSError as ex:
+        raise CannotConnect from ex
+
+    # Return info that you want to store in the config entry.
+    return {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
+
+
+class BMWConnectedDriveConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for BMW ConnectedDrive."""
+
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+    async def async_step_user(self, user_input=None):
+        """Handle the initial step."""
+        errors = {}
+        if user_input is not None:
+            unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
+
+            await self.async_set_unique_id(unique_id)
+            self._abort_if_unique_id_configured()
+
+            info = None
+            try:
+                info = await validate_input(self.hass, user_input)
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+
+            if info:
+                return self.async_create_entry(title=info["title"], data=user_input)
+
+        return self.async_show_form(
+            step_id="user", data_schema=DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_import(self, user_input):
+        """Handle import."""
+        return await self.async_step_user(user_input)
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(config_entry):
+        """Return a BWM ConnectedDrive option flow."""
+        return BMWConnectedDriveOptionsFlow(config_entry)
+
+
+class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow):
+    """Handle a option flow for BMW ConnectedDrive."""
+
+    def __init__(self, config_entry):
+        """Initialize BMW ConnectedDrive option flow."""
+        self.config_entry = config_entry
+        self.options = dict(config_entry.options)
+
+    async def async_step_init(self, user_input=None):
+        """Manage the options."""
+        return await self.async_step_account_options()
+
+    async def async_step_account_options(self, user_input=None):
+        """Handle the initial step."""
+        if user_input is not None:
+            return self.async_create_entry(title="", data=user_input)
+        return self.async_show_form(
+            step_id="account_options",
+            data_schema=vol.Schema(
+                {
+                    vol.Optional(
+                        CONF_READ_ONLY,
+                        default=self.config_entry.options.get(CONF_READ_ONLY, False),
+                    ): bool,
+                    vol.Optional(
+                        CONF_USE_LOCATION,
+                        default=self.config_entry.options.get(CONF_USE_LOCATION, False),
+                    ): bool,
+                }
+            ),
+        )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+    """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py
index d1a44b5e5c9..65dc7fde595 100644
--- a/homeassistant/components/bmw_connected_drive/const.py
+++ b/homeassistant/components/bmw_connected_drive/const.py
@@ -1,2 +1,12 @@
 """Const file for the BMW Connected Drive integration."""
 ATTRIBUTION = "Data provided by BMW Connected Drive"
+
+CONF_REGION = "region"
+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"
diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py
index fa732b64e77..7f069e741b8 100644
--- a/homeassistant/components/bmw_connected_drive/device_tracker.py
+++ b/homeassistant/components/bmw_connected_drive/device_tracker.py
@@ -1,51 +1,83 @@
 """Device tracker for BMW Connected Drive vehicles."""
 import logging
 
-from homeassistant.util import slugify
+from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
+from homeassistant.components.device_tracker.config_entry import TrackerEntity
 
-from . import DOMAIN as BMW_DOMAIN
+from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity
+from .const import CONF_ACCOUNT, DATA_ENTRIES
 
 _LOGGER = logging.getLogger(__name__)
 
 
-def setup_scanner(hass, config, see, discovery_info=None):
-    """Set up the BMW tracker."""
-    accounts = hass.data[BMW_DOMAIN]
-    _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts]))
-    for account in accounts:
-        for vehicle in account.account.vehicles:
-            tracker = BMWDeviceTracker(see, vehicle)
-            account.add_update_listener(tracker.update)
-            tracker.update()
-    return True
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the BMW ConnectedDrive tracker from config entry."""
+    account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT]
+    entities = []
 
+    for vehicle in account.account.vehicles:
+        entities.append(BMWDeviceTracker(account, vehicle))
+        if not vehicle.state.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)
 
-class BMWDeviceTracker:
+
+class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
     """BMW Connected Drive device tracker."""
 
-    def __init__(self, see, vehicle):
+    def __init__(self, account, vehicle):
         """Initialize the Tracker."""
-        self._see = see
-        self.vehicle = vehicle
-
-    def update(self) -> None:
-        """Update the device info.
-
-        Only update the state in Home Assistant if tracking in
-        the car is enabled.
-        """
-        dev_id = slugify(self.vehicle.name)
-
-        if not self.vehicle.state.is_vehicle_tracking_enabled:
-            _LOGGER.debug("Tracking is disabled for vehicle %s", dev_id)
-            return
-
-        _LOGGER.debug("Updating %s", dev_id)
-        attrs = {"vin": self.vehicle.vin}
-        self._see(
-            dev_id=dev_id,
-            host_name=self.vehicle.name,
-            gps=self.vehicle.state.gps_position,
-            attributes=attrs,
-            icon="mdi:car",
+        super().__init__(account, vehicle)
+
+        self._unique_id = vehicle.vin
+        self._location = (
+            vehicle.state.gps_position if vehicle.state.gps_position else (None, None)
+        )
+        self._name = vehicle.name
+
+    @property
+    def latitude(self):
+        """Return latitude value of the device."""
+        return self._location[0]
+
+    @property
+    def longitude(self):
+        """Return longitude value of the device."""
+        return self._location[1]
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self):
+        """Return the unique ID."""
+        return self._unique_id
+
+    @property
+    def source_type(self):
+        """Return the source type, eg gps or router, of the device."""
+        return SOURCE_TYPE_GPS
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend, if any."""
+        return "mdi:car"
+
+    @property
+    def force_update(self):
+        """All updates do not need to be written to the state machine."""
+        return False
+
+    def update(self):
+        """Update state of the decvice tracker."""
+        self._location = (
+            self._vehicle.state.gps_position
+            if self._vehicle.state.is_vehicle_tracking_enabled
+            else (None, None)
         )
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index d30f1702ae8..0d281e78f14 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -4,35 +4,34 @@ import logging
 from bimmer_connected.state import LockState
 
 from homeassistant.components.lock import LockEntity
-from homeassistant.const import ATTR_ATTRIBUTION, STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
 
-from . import DOMAIN as BMW_DOMAIN
-from .const import ATTRIBUTION
+from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity
+from .const import CONF_ACCOUNT, DATA_ENTRIES
 
 DOOR_LOCK_STATE = "door_lock_state"
 _LOGGER = logging.getLogger(__name__)
 
 
-def setup_platform(hass, config, add_entities, discovery_info=None):
-    """Set up the BMW Connected Drive lock."""
-    accounts = hass.data[BMW_DOMAIN]
-    _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts]))
-    devices = []
-    for account in accounts:
-        if not account.read_only:
-            for vehicle in account.account.vehicles:
-                device = BMWLock(account, vehicle, "lock", "BMW lock")
-                devices.append(device)
-    add_entities(devices, True)
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the BMW ConnectedDrive binary sensors from config entry."""
+    account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT]
+    entities = []
 
+    if not account.read_only:
+        for vehicle in account.account.vehicles:
+            device = BMWLock(account, vehicle, "lock", "BMW lock")
+            entities.append(device)
+    async_add_entities(entities, True)
 
-class BMWLock(LockEntity):
+
+class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
     """Representation of a BMW vehicle lock."""
 
     def __init__(self, account, vehicle, attribute: str, sensor_name):
         """Initialize the lock."""
-        self._account = account
-        self._vehicle = vehicle
+        super().__init__(account, vehicle)
+
         self._attribute = attribute
         self._name = f"{self._vehicle.name} {self._attribute}"
         self._unique_id = f"{self._vehicle.vin}-{self._attribute}"
@@ -42,14 +41,6 @@ class BMWLock(LockEntity):
             DOOR_LOCK_STATE in self._vehicle.available_attributes
         )
 
-    @property
-    def should_poll(self):
-        """Do not poll this class.
-
-        Updates are triggered from BMWConnectedDriveAccount.
-        """
-        return False
-
     @property
     def unique_id(self):
         """Return the unique ID of the lock."""
@@ -64,10 +55,8 @@ class BMWLock(LockEntity):
     def device_state_attributes(self):
         """Return the state attributes of the lock."""
         vehicle_state = self._vehicle.state
-        result = {
-            "car": self._vehicle.name,
-            ATTR_ATTRIBUTION: ATTRIBUTION,
-        }
+        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
@@ -76,7 +65,11 @@ class BMWLock(LockEntity):
     @property
     def is_locked(self):
         """Return true if lock is locked."""
-        return self._state == STATE_LOCKED
+        if self.door_lock_state_available:
+            result = self._state == STATE_LOCKED
+        else:
+            result = None
+        return result
 
     def lock(self, **kwargs):
         """Lock the car."""
@@ -107,14 +100,3 @@ class BMWLock(LockEntity):
             if vehicle_state.door_lock_state in [LockState.LOCKED, LockState.SECURED]
             else STATE_UNLOCKED
         )
-
-    def update_callback(self):
-        """Schedule a state update."""
-        self.schedule_update_ha_state(True)
-
-    async def async_added_to_hass(self):
-        """Add callback after being added to hass.
-
-        Show latest data after startup.
-        """
-        self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index cb17459e105..5bce904e1cd 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -3,5 +3,6 @@
   "name": "BMW Connected Drive",
   "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
   "requirements": ["bimmer_connected==0.7.13"],
-  "codeowners": ["@gerard33", "@rikroe"]
+  "codeowners": ["@gerard33", "@rikroe"],
+  "config_flow": true
 }
diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py
index 9cf2bca2df5..3fd40f3801c 100644
--- a/homeassistant/components/bmw_connected_drive/notify.py
+++ b/homeassistant/components/bmw_connected_drive/notify.py
@@ -11,6 +11,7 @@ from homeassistant.components.notify import (
 from homeassistant.const import ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, ATTR_NAME
 
 from . import DOMAIN as BMW_DOMAIN
+from .const import CONF_ACCOUNT, DATA_ENTRIES
 
 ATTR_LAT = "lat"
 ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
@@ -23,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
 
 def get_service(hass, config, discovery_info=None):
     """Get the BMW notification service."""
-    accounts = hass.data[BMW_DOMAIN]
+    accounts = [e[CONF_ACCOUNT] for e in hass.data[BMW_DOMAIN][DATA_ENTRIES].values()]
     _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts]))
     svc = BMWNotificationService()
     svc.setup(accounts)
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index 4668b1da6eb..480aac34eb3 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -4,7 +4,6 @@ import logging
 from bimmer_connected.state import ChargingState
 
 from homeassistant.const import (
-    ATTR_ATTRIBUTION,
     CONF_UNIT_SYSTEM_IMPERIAL,
     LENGTH_KILOMETERS,
     LENGTH_MILES,
@@ -16,8 +15,8 @@ from homeassistant.const import (
 from homeassistant.helpers.entity import Entity
 from homeassistant.helpers.icon import icon_for_battery_level
 
-from . import DOMAIN as BMW_DOMAIN
-from .const import ATTRIBUTION
+from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity
+from .const import CONF_ACCOUNT, DATA_ENTRIES
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -48,48 +47,39 @@ ATTR_TO_HA_IMPERIAL = {
 }
 
 
-def setup_platform(hass, config, add_entities, discovery_info=None):
-    """Set up the BMW sensors."""
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the BMW ConnectedDrive sensors from config entry."""
     if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
         attribute_info = ATTR_TO_HA_IMPERIAL
     else:
         attribute_info = ATTR_TO_HA_METRIC
 
-    accounts = hass.data[BMW_DOMAIN]
-    _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts]))
-    devices = []
-    for account in accounts:
-        for vehicle in account.account.vehicles:
-            for attribute_name in vehicle.drive_train_attributes:
-                if attribute_name in vehicle.available_attributes:
-                    device = BMWConnectedDriveSensor(
-                        account, vehicle, attribute_name, attribute_info
-                    )
-                    devices.append(device)
-    add_entities(devices, True)
-
-
-class BMWConnectedDriveSensor(Entity):
+    account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT]
+    entities = []
+
+    for vehicle in account.account.vehicles:
+        for attribute_name in vehicle.drive_train_attributes:
+            if attribute_name in vehicle.available_attributes:
+                device = BMWConnectedDriveSensor(
+                    account, vehicle, attribute_name, attribute_info
+                )
+                entities.append(device)
+    async_add_entities(entities, True)
+
+
+class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, Entity):
     """Representation of a BMW vehicle sensor."""
 
     def __init__(self, account, vehicle, attribute: str, attribute_info):
         """Initialize BMW vehicle sensor."""
-        self._vehicle = vehicle
-        self._account = account
+        super().__init__(account, vehicle)
+
         self._attribute = attribute
         self._state = None
         self._name = f"{self._vehicle.name} {self._attribute}"
         self._unique_id = f"{self._vehicle.vin}-{self._attribute}"
         self._attribute_info = attribute_info
 
-    @property
-    def should_poll(self) -> bool:
-        """Return False.
-
-        Data update is triggered from BMWConnectedDriveEntity.
-        """
-        return False
-
     @property
     def unique_id(self):
         """Return the unique ID of the sensor."""
@@ -128,14 +118,6 @@ class BMWConnectedDriveSensor(Entity):
         unit = self._attribute_info.get(self._attribute, [None, None])[1]
         return unit
 
-    @property
-    def device_state_attributes(self):
-        """Return the state attributes of the sensor."""
-        return {
-            "car": self._vehicle.name,
-            ATTR_ATTRIBUTION: ATTRIBUTION,
-        }
-
     def update(self) -> None:
         """Read new state data from the library."""
         _LOGGER.debug("Updating %s", self._vehicle.name)
@@ -152,14 +134,3 @@ class BMWConnectedDriveSensor(Entity):
             self._state = round(value_converted)
         else:
             self._state = getattr(vehicle_state, self._attribute)
-
-    def update_callback(self):
-        """Schedule a state update."""
-        self.schedule_update_ha_state(True)
-
-    async def async_added_to_hass(self):
-        """Add callback after being added to hass.
-
-        Show latest data after startup.
-        """
-        self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
new file mode 100644
index 00000000000..c0c45b814a4
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -0,0 +1,30 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "data": {
+          "username": "[%key:common::config_flow::data::username%]",
+          "password": "[%key:common::config_flow::data::password%]",
+          "region": "ConnectedDrive Region"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+    },
+    "abort": {
+      "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+    }
+  },
+  "options": {
+    "step": {
+      "account_options": {
+        "data": {
+          "read_only": "Read-only (only sensors and notify, no execution of services, no lock)",
+          "use_location": "Use Home Assistant location for car location polls (required for non i3/i8 vehicles produced before 7/2014)"
+        }
+      }
+    }
+  }
+}
diff --git a/homeassistant/components/bmw_connected_drive/translations/en.json b/homeassistant/components/bmw_connected_drive/translations/en.json
new file mode 100644
index 00000000000..f194c8a3444
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/translations/en.json
@@ -0,0 +1,31 @@
+{
+    "config": {
+        "abort": {
+            "already_configured": "Account is already configured"
+        },
+        "error": {
+            "cannot_connect": "Failed to connect",
+            "invalid_auth": "Invalid authentication"
+        },
+        "step": {
+            "user": {
+                "data": {
+                    "password": "Password",
+                    "read_only": "Read-only",
+                    "region": "ConnectedDrive Region",
+                    "username": "Username"
+                }
+            }
+        }
+    },
+    "options": {
+        "step": {
+            "account_options": {
+                "data": {
+                    "read_only": "Read-only (only sensors and notify, no execution of services, no lock)",
+                    "use_location": "Use Home Assistant location for car location polls (required for non i3/i8 vehicles produced before 7/2014)"
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 11a0b517646..9e204e91da5 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -28,6 +28,7 @@ FLOWS = [
     "azure_devops",
     "blebox",
     "blink",
+    "bmw_connected_drive",
     "bond",
     "braviatv",
     "broadlink",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 7ff9bd8c974..73503ac0e17 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -188,6 +188,9 @@ base36==0.1.1
 # homeassistant.components.zha
 bellows==0.21.0
 
+# homeassistant.components.bmw_connected_drive
+bimmer_connected==0.7.13
+
 # homeassistant.components.blebox
 blebox_uniapi==1.3.2
 
diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py
new file mode 100644
index 00000000000..e1243fe2c0a
--- /dev/null
+++ b/tests/components/bmw_connected_drive/__init__.py
@@ -0,0 +1 @@
+"""Tests for the for the BMW Connected Drive integration."""
diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py
new file mode 100644
index 00000000000..ae32feec7b1
--- /dev/null
+++ b/tests/components/bmw_connected_drive/test_config_flow.py
@@ -0,0 +1,153 @@
+"""Test the for the BMW Connected Drive config flow."""
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
+from homeassistant.components.bmw_connected_drive.const import (
+    CONF_READ_ONLY,
+    CONF_REGION,
+    CONF_USE_LOCATION,
+)
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+FIXTURE_USER_INPUT = {
+    CONF_USERNAME: "user@domain.com",
+    CONF_PASSWORD: "p4ssw0rd",
+    CONF_REGION: "rest_of_world",
+}
+FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy()
+FIXTURE_IMPORT_ENTRY = FIXTURE_USER_INPUT.copy()
+
+FIXTURE_CONFIG_ENTRY = {
+    "entry_id": "1",
+    "domain": DOMAIN,
+    "title": FIXTURE_USER_INPUT[CONF_USERNAME],
+    "data": {
+        CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME],
+        CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD],
+        CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION],
+    },
+    "options": {CONF_READ_ONLY: False, CONF_USE_LOCATION: False},
+    "system_options": {"disable_new_entities": False},
+    "source": "user",
+    "connection_class": config_entries.CONN_CLASS_CLOUD_POLL,
+    "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}",
+}
+
+
+async def test_show_form(hass):
+    """Test that the form is served with no input."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+
+
+async def test_connection_error(hass):
+    """Test we show user form on BMW connected drive connection error."""
+
+    def _mock_get_oauth_token(*args, **kwargs):
+        pass
+
+    with patch(
+        "bimmer_connected.account.ConnectedDriveAccount._get_oauth_token",
+        side_effect=OSError,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": config_entries.SOURCE_USER},
+            data=FIXTURE_USER_INPUT,
+        )
+
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+    assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_full_user_flow_implementation(hass):
+    """Test registering an integration and finishing flow works."""
+    with patch(
+        "bimmer_connected.account.ConnectedDriveAccount._get_vehicles",
+        return_value=[],
+    ), patch(
+        "homeassistant.components.bmw_connected_drive.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.bmw_connected_drive.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+        result2 = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": config_entries.SOURCE_USER},
+            data=FIXTURE_USER_INPUT,
+        )
+        assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+        assert result2["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
+        assert result2["data"] == FIXTURE_COMPLETE_ENTRY
+
+        assert len(mock_setup.mock_calls) == 1
+        assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_full_config_flow_implementation(hass):
+    """Test registering an integration and finishing flow works."""
+    with patch(
+        "bimmer_connected.account.ConnectedDriveAccount._get_vehicles",
+        return_value=[],
+    ), patch(
+        "homeassistant.components.bmw_connected_drive.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.bmw_connected_drive.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": config_entries.SOURCE_IMPORT},
+            data=FIXTURE_USER_INPUT,
+        )
+
+        assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+        assert result["title"] == FIXTURE_IMPORT_ENTRY[CONF_USERNAME]
+        assert result["data"] == FIXTURE_IMPORT_ENTRY
+
+        assert len(mock_setup.mock_calls) == 1
+        assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_options_flow_implementation(hass):
+    """Test config flow options."""
+    with patch(
+        "bimmer_connected.account.ConnectedDriveAccount._get_vehicles",
+        return_value=[],
+    ), patch(
+        "homeassistant.components.bmw_connected_drive.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.bmw_connected_drive.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+        config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
+        config_entry.add_to_hass(hass)
+
+        await hass.config_entries.async_setup(config_entry.entry_id)
+        await hass.async_block_till_done()
+
+        result = await hass.config_entries.options.async_init(config_entry.entry_id)
+        assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+        assert result["step_id"] == "account_options"
+
+        result = await hass.config_entries.options.async_configure(
+            result["flow_id"],
+            user_input={CONF_READ_ONLY: False, CONF_USE_LOCATION: False},
+        )
+        await hass.async_block_till_done()
+
+        assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+        assert result["data"] == {
+            CONF_READ_ONLY: False,
+            CONF_USE_LOCATION: False,
+        }
+
+        assert len(mock_setup.mock_calls) == 1
+        assert len(mock_setup_entry.mock_calls) == 1
-- 
GitLab