diff --git a/.coveragerc b/.coveragerc index 771333c1c32..2bbbb93cda5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -212,6 +212,8 @@ omit = homeassistant/components/doorbird/camera.py homeassistant/components/doorbird/entity.py homeassistant/components/doorbird/util.py + homeassistant/components/dormakaba_dkey/__init__.py + homeassistant/components/dormakaba_dkey/lock.py homeassistant/components/dovado/* homeassistant/components/downloader/* homeassistant/components/dsmr_reader/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index cf3e4496b66..f241f08e1ac 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,8 @@ build.json @home-assistant/supervisor /tests/components/dnsip/ @gjohansson-ST /homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket +/homeassistant/components/dormakaba_dkey/ @emontnemery +/tests/components/dormakaba_dkey/ @emontnemery /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py new file mode 100644 index 00000000000..8162fb68bb7 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -0,0 +1,97 @@ +"""The Dormakaba dKey integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from py_dormakaba_dkey import DKEYLock +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.models import AssociationData + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS +from .models import DormakabaDkeyData + +PLATFORMS: list[Platform] = [Platform.LOCK] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Dormakaba dKey from a config entry.""" + address: str = entry.data[CONF_ADDRESS] + ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) + if not ble_device: + raise ConfigEntryNotReady(f"Could not find dKey device with address {address}") + + lock = DKEYLock(ble_device) + lock.set_association_data( + AssociationData.from_json(entry.data[CONF_ASSOCIATION_DATA]) + ) + + @callback + def _async_update_ble( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback.""" + lock.set_ble_device_and_advertisement_data( + service_info.device, service_info.advertisement + ) + + entry.async_on_unload( + bluetooth.async_register_callback( + hass, + _async_update_ble, + BluetoothCallbackMatcher({ADDRESS: address}), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + ) + + async def _async_update() -> None: + """Update the device state.""" + try: + await lock.update() + await lock.disconnect() + except DKEY_EXCEPTIONS as ex: + raise UpdateFailed(str(ex)) from ex + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=lock.name, + update_method=_async_update, + update_interval=timedelta(seconds=UPDATE_SECONDS), + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( + lock, coordinator + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_stop(event: Event) -> None: + """Close the connection.""" + await lock.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: DormakabaDkeyData = hass.data[DOMAIN].pop(entry.entry_id) + await data.lock.disconnect() + + return unload_ok diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py new file mode 100644 index 00000000000..dca19c802b1 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Dormakaba dKey integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from py_dormakaba_dkey import DKEYLock, device_filter, errors as dkey_errors +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_ASSOCIATION_DATA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_ASSOCIATE_SCHEMA = vol.Schema( + { + vol.Required("activation_code"): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Dormakaba dKey.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._lock: DKEYLock | None = None + # Populated by user step + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + # Populated by bluetooth and user steps + self._discovery_info: BluetoothServiceInfoBleak | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_associate() + + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or not device_filter(discovery.advertisement) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the Bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + name = self._discovery_info.name or self._discovery_info.address + self.context["title_placeholders"] = {"name": name} + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle bluetooth confirm step.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + return await self.async_step_associate() + + async def async_step_associate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle associate step.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if user_input is None: + return self.async_show_form( + step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA + ) + + errors = {} + if not self._lock: + self._lock = DKEYLock(self._discovery_info.device) + lock = self._lock + + try: + association_data = await lock.associate(user_input["activation_code"]) + except BleakError: + return self.async_abort(reason="cannot_connect") + except dkey_errors.InvalidActivationCode: + errors["base"] = "invalid_code" + except dkey_errors.WrongActivationCode: + errors["base"] = "wrong_code" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=lock.device_info.device_name + or lock.device_info.device_id + or lock.name, + data={ + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + }, + ) + + return self.async_show_form( + step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/dormakaba_dkey/const.py b/homeassistant/components/dormakaba_dkey/const.py new file mode 100644 index 00000000000..8d93fefbdad --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/const.py @@ -0,0 +1,7 @@ +"""Constants for the Dormakaba dKey integration.""" + +DOMAIN = "dormakaba_dkey" + +UPDATE_SECONDS = 120 + +CONF_ASSOCIATION_DATA = "association_data" diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py new file mode 100644 index 00000000000..dffebdd6bc8 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -0,0 +1,84 @@ +"""Dormakaba dKey integration lock platform.""" +from __future__ import annotations + +from typing import Any + +from py_dormakaba_dkey import DKEYLock +from py_dormakaba_dkey.commands import Notifications, UnlockStatus + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .models import DormakabaDkeyData + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the lock platform for Dormakaba dKey.""" + data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)]) + + +class DormakabaDkeyLock(CoordinatorEntity[DataUpdateCoordinator[None]], LockEntity): + """Representation of Dormakaba dKey lock.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock + ) -> None: + """Initialize a Dormakaba dKey lock.""" + super().__init__(coordinator) + self._lock = lock + self._attr_unique_id = lock.address + self._attr_device_info = DeviceInfo( + name=lock.device_info.device_name or lock.device_info.device_id, + model="MTL 9291", + sw_version=lock.device_info.sw_version, + connections={(dr.CONNECTION_BLUETOOTH, lock.address)}, + ) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + self._attr_is_locked = self._lock.state.unlock_status in ( + UnlockStatus.LOCKED, + UnlockStatus.SECURITY_LOCKED, + ) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._lock.lock() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + await self._lock.unlock() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._async_update_attrs() + self.async_write_ha_state() + + @callback + def _handle_state_update(self, update: Notifications) -> None: + """Handle data update.""" + self.coordinator.async_set_updated_data(None) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.async_on_remove(self._lock.register_callback(self._handle_state_update)) + return await super().async_added_to_hass() diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json new file mode 100644 index 00000000000..55ffaad4b3d --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "dormakaba_dkey", + "name": "Dormakaba dKey", + "bluetooth": [ + { "service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897" }, + { "service_uuid": "e7a60001-6639-429f-94fd-86de8ea26897" } + ], + "codeowners": ["@emontnemery"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["py-dormakaba-dkey==1.0.1"] +} diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py new file mode 100644 index 00000000000..cd260c15e81 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/models.py @@ -0,0 +1,16 @@ +"""The Dormakaba dKey integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_dormakaba_dkey import DKEYLock + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +@dataclass +class DormakabaDkeyData: + """Data for the Dormakaba dKey integration.""" + + lock: DKEYLock + coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json new file mode 100644 index 00000000000..d07deaca829 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "associate": { + "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", + "data": { + "activation_code": "Activation code" + } + } + }, + "error": { + "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", + "wrong_code": "Wrong activation code. Note that an activation code can only be used once." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 031791ca106..86da242be80 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -42,6 +42,14 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "bthome", "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb", }, + { + "domain": "dormakaba_dkey", + "service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897", + }, + { + "domain": "dormakaba_dkey", + "service_uuid": "e7a60001-6639-429f-94fd-86de8ea26897", + }, { "domain": "eufylife_ble", "local_name": "eufy T9140", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d52e3f2bef2..28ceb593845 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -97,6 +97,7 @@ FLOWS = { "dlna_dms", "dnsip", "doorbird", + "dormakaba_dkey", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3a1c8bf7fe8..d1e1307d8bc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1138,6 +1138,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "dormakaba_dkey": { + "name": "Dormakaba dKey", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "dovado": { "name": "Dovado", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 5ecdd8f53e4..326c5b596ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1429,6 +1429,9 @@ py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 +# homeassistant.components.dormakaba_dkey +py-dormakaba-dkey==1.0.1 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a204c021e8a..7696d98ba3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1044,6 +1044,9 @@ py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 +# homeassistant.components.dormakaba_dkey +py-dormakaba-dkey==1.0.1 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/tests/components/dormakaba_dkey/__init__.py b/tests/components/dormakaba_dkey/__init__.py new file mode 100644 index 00000000000..12396a8c82b --- /dev/null +++ b/tests/components/dormakaba_dkey/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the Dormakaba dKey integration.""" +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data + +DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"], + service_data={}, + source="local", + device=BLEDevice(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"] + ), + time=0, + connectable=True, +) + + +NOT_DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not", + address="AA:BB:CC:DD:EE:F2", + rssi=-60, + manufacturer_data={ + 33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9", + 21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0", + }, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(address="AA:BB:CC:DD:EE:F2", name="Aug"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) diff --git a/tests/components/dormakaba_dkey/conftest.py b/tests/components/dormakaba_dkey/conftest.py new file mode 100644 index 00000000000..d911739943f --- /dev/null +++ b/tests/components/dormakaba_dkey/conftest.py @@ -0,0 +1,8 @@ +"""Dormakaba dKey test fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py new file mode 100644 index 00000000000..c983f70a0c8 --- /dev/null +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -0,0 +1,296 @@ +"""Test the Dormakaba dKey config flow.""" +from unittest.mock import patch + +from bleak.exc import BleakError +from py_dormakaba_dkey import errors as dkey_errors +from py_dormakaba_dkey.models import AssociationData +import pytest + +from homeassistant import config_entries +from homeassistant.components.dormakaba_dkey.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import DKEY_DISCOVERY_INFO, NOT_DKEY_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[NOT_DKEY_DISCOVERY_INFO, DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[NOT_DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: + """Test user step with only existing devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + unique_id=DKEY_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_device_added_between_steps_1(hass: HomeAssistant) -> None: + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": DKEY_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_user_takes_precedence_over_discovery(hass): + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success(hass: HomeAssistant, result: FlowResult) -> None: + """Test bluetooth and user flow success paths.""" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + return_value=AssociationData(b"1234", b"AABBCCDD"), + ) as mock_associate, patch( + "homeassistant.components.dormakaba_dkey.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DKEY_DISCOVERY_INFO.name + assert result["data"] == { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + "association_data": {"key_holder_id": "31323334", "secret": "4141424243434444"}, + } + assert result["options"] == {} + assert result["result"].unique_id == DKEY_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + mock_associate.assert_awaited_once_with("1234-1234") + + +async def test_bluetooth_step_already_configured(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=DKEY_DISCOVERY_INFO.address) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_bluetooth_step_already_in_progress(hass): + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.parametrize( + "exc, error", + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + ), +) +async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth step and we cannot connect.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + "exc, error", + ( + (dkey_errors.InvalidActivationCode, "invalid_code"), + (dkey_errors.WrongActivationCode, "wrong_code"), + ), +) +async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth step and we cannot associate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=DKEY_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] == {"base": error}