diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..16133b5a1c1 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,41 @@ +"""The BMW Connected Drive integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "bmw_connected_drive" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BMW Connected Drive from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/bmw_connected_drive", + "custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha", + }, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) 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..7295864c29c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -0,0 +1,9 @@ +"""The BMW Connected Drive integration config flow.""" + +from homeassistant.config_entries import ConfigFlow + +from . import DOMAIN + + +class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BMW Connected Drive.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json new file mode 100644 index 00000000000..b1c3cc9769b --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bmw_connected_drive", + "name": "BMW Connected Drive", + "codeowners": [], + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "integration_type": "system", + "iot_class": "cloud_polling", + "quality_scale": "legacy", + "requirements": [] +} diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json new file mode 100644 index 00000000000..7ff1b1eb99c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", + "title": "The BMW Connected Drive integration has been removed" + } + } +} diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 94b23e32b55..8ff2f158598 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -212,6 +212,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", @@ -1183,6 +1184,7 @@ INTEGRATIONS_WITHOUT_SCALE = [ "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..265e4b0c4f5 --- /dev/null +++ b/tests/components/bmw_connected_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the BMW Connected Drive integration.""" diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py new file mode 100644 index 00000000000..c05c98d07f7 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the BMW Connected Drive integration.""" + +from homeassistant.components.bmw_connected_drive import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_bmw_connected_drive_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the BMW Connected Drive configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN)