From 399ef3bfbdbcbac11a718446f539b58ea66496a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Feb 2023 22:47:52 +0100 Subject: [PATCH 01/14] Add support for dormakaba dKey locks --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/dormakaba_dkey/__init__.py | 130 ++++++++++++++ .../components/dormakaba_dkey/config_flow.py | 150 ++++++++++++++++ .../components/dormakaba_dkey/const.py | 6 + .../components/dormakaba_dkey/lock.py | 84 +++++++++ .../components/dormakaba_dkey/manifest.json | 22 +++ .../components/dormakaba_dkey/models.py | 17 ++ .../components/dormakaba_dkey/strings.json | 33 ++++ .../dormakaba_dkey/translations/en.json | 33 ++++ homeassistant/generated/bluetooth.py | 8 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/dormakaba_dkey/__init__.py | 20 +++ tests/components/dormakaba_dkey/conftest.py | 8 + .../dormakaba_dkey/test_config_flow.py | 169 ++++++++++++++++++ 18 files changed, 697 insertions(+) create mode 100644 homeassistant/components/dormakaba_dkey/__init__.py create mode 100644 homeassistant/components/dormakaba_dkey/config_flow.py create mode 100644 homeassistant/components/dormakaba_dkey/const.py create mode 100644 homeassistant/components/dormakaba_dkey/lock.py create mode 100644 homeassistant/components/dormakaba_dkey/manifest.json create mode 100644 homeassistant/components/dormakaba_dkey/models.py create mode 100644 homeassistant/components/dormakaba_dkey/strings.json create mode 100644 homeassistant/components/dormakaba_dkey/translations/en.json create mode 100644 tests/components/dormakaba_dkey/__init__.py create mode 100644 tests/components/dormakaba_dkey/conftest.py create mode 100644 tests/components/dormakaba_dkey/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 771333c1c3255..2bbbb93cda5e7 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 cf3e4496b661c..f241f08e1accc 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 0000000000000..05b09862d5378 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -0,0 +1,130 @@ +"""The Dormakaba dKey integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +import async_timeout +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.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS +from .models import DormakabaDkeyData + +PLATFORMS: list[Platform] = [Platform.LOCK] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Dormakaba dKey integration.""" + hass.data[DOMAIN] = {} + return True + + +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["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(): + """Update the device state.""" + try: + await lock.update() + await lock.disconnect() + except DKEY_EXCEPTIONS as ex: + raise UpdateFailed(str(ex)) from ex + + startup_event = asyncio.Event() + cancel_first_update = lock.register_callback(lambda *_: startup_event.set()) + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=lock.name, + update_method=_async_update, + update_interval=timedelta(seconds=UPDATE_SECONDS), + ) + + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + cancel_first_update() + raise + + try: + async with async_timeout.timeout(DEVICE_TIMEOUT): + await startup_event.wait() + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady( + "Unable to communicate with the device; " + f"Try moving the Bluetooth adapter closer to {lock.name}" + ) from ex + finally: + cancel_first_update() + + hass.data[DOMAIN][entry.entry_id] = DormakabaDkeyData( + entry.title, lock, coordinator + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + 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_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.title: + await hass.config_entries.async_reload(entry.entry_id) + + +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 0000000000000..889b3eafca570 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -0,0 +1,150 @@ +"""Config flow for Dormakaba dKey integration.""" +from __future__ import annotations + +from asyncio import Task +import logging +from typing import Any + +from bleak import BleakError +from py_dormakaba_dkey import DKEYLock, errors as dkey_errors +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import 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 + _lock: DKEYLock + + def __init__(self) -> None: + """Initialize the config flow.""" + self._connect_task: Task | None = None + self._discovery_info: BluetoothServiceInfoBleak | None = None + + 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_bluetooth_connect() + + async def async_step_bluetooth_connect( + 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 + + async def _connect() -> None: + try: + await self._lock.connect() + # Disconnect again so the user can use the admin app to create a new key + await self._lock.disconnect() + finally: + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + if not self._connect_task: + self._lock = DKEYLock(self._discovery_info.device) + self._connect_task = self.hass.async_create_task(_connect()) + return self.async_show_progress( + step_id="bluetooth_connect", + progress_action="wait_for_bluetooth_connect", + ) + + try: + await self._connect_task + except BleakError as err: + _LOGGER.info("Could not connect to lock", exc_info=err) + return self.async_show_progress_done(next_step_id="could_not_connect") + except Exception as err: + _LOGGER.error("Unknown error when connecting to lock", exc_info=err) + return self.async_show_progress_done(next_step_id="could_not_connect") + finally: + self._connect_task = None + + return self.async_show_progress_done(next_step_id="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 = {} + lock = self._lock + + try: + association_data = await lock.associate(user_input["activation_code"]) + 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") + errors["base"] = "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, + "association_data": association_data.to_json(), + }, + ) + + return self.async_show_form( + step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors + ) + + async def async_step_could_not_connect( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle connection failure.""" + if user_input is None: + return self.async_show_form(step_id="could_not_connect") + + return await self.async_step_bluetooth_connect() diff --git a/homeassistant/components/dormakaba_dkey/const.py b/homeassistant/components/dormakaba_dkey/const.py new file mode 100644 index 0000000000000..c4c653651e08d --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/const.py @@ -0,0 +1,6 @@ +"""Constants for the Dormakaba dKey integration.""" + +DOMAIN = "dormakaba_dkey" + +DEVICE_TIMEOUT = 30 +UPDATE_SECONDS = 120 diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py new file mode 100644 index 0000000000000..97677d8f0a056 --- /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, entry.title)]) + + +class DormakabaDkeyLock(CoordinatorEntity, LockEntity): + """Representation of Dormakaba dKey lock.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, lock: DKEYLock, name: str + ) -> 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 0000000000000..b549c88607e0e --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -0,0 +1,22 @@ +{ + "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", + "homekit": {}, + "iot_class": "local_polling", + "requirements": ["py-dormakaba-dkey==1.0.0"], + "ssdp": [], + "zeroconf": [], + "integration_type": "device" +} diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py new file mode 100644 index 0000000000000..b26097fd839c1 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/models.py @@ -0,0 +1,17 @@ +"""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.""" + + title: str + lock: DKEYLock + coordinator: DataUpdateCoordinator diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json new file mode 100644 index 0000000000000..12483bc9ea079 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "bluetooth_connect": { + "description": "Please wait while a connection with the lock is established" + }, + "could_not_connect": { + "description": "Could not connect to the lock, do you want to try again?" + }, + "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 activaction code. An activaction code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", + "wrong_code": "Wrong activaction code. Note that an activation code can only be used once.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "wait_for_bluetooth_connect": "Connecting..." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/dormakaba_dkey/translations/en.json b/homeassistant/components/dormakaba_dkey/translations/en.json new file mode 100644 index 0000000000000..27e3aea53bf41 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_code": "Invalid activaction code. An activaction code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", + "unknown": "Unexpected error", + "wrong_code": "Wrong activaction code. Note that an activation code can only be used once." + }, + "flow_title": "{name}", + "progress": { + "wait_for_bluetooth_connect": "Connecting..." + }, + "step": { + "associate": { + "data": { + "activation_code": "Activation code" + }, + "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." + }, + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + }, + "bluetooth_connect": { + "description": "Please wait while a connection with the lock is established" + }, + "could_not_connect": { + "description": "Could not connect to the lock, do you want to try again?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 031791ca10618..86da242be80ab 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -42,6 +42,14 @@ "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 d52e3f2bef28b..28ceb593845bb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -97,6 +97,7 @@ "dlna_dms", "dnsip", "doorbird", + "dormakaba_dkey", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3a1c8bf7fe86a..d1e1307d8bcfd 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 c91c7099e777e..97af26080089e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1432,6 +1432,9 @@ py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 +# homeassistant.components.dormakaba_dkey +py-dormakaba-dkey==1.0.0 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6735c90aace8..4be4258f06678 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1047,6 +1047,9 @@ py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 +# homeassistant.components.dormakaba_dkey +py-dormakaba-dkey==1.0.0 + # 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 0000000000000..265b7ba8d3601 --- /dev/null +++ b/tests/components/dormakaba_dkey/__init__.py @@ -0,0 +1,20 @@ +"""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:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="00123456"), + 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 0000000000000..d911739943f52 --- /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 0000000000000..8c8bf5bca5b41 --- /dev/null +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -0,0 +1,169 @@ +"""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 FlowResultType + +from . import DKEY_DISCOVERY_INFO + + +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"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "bluetooth_connect" + assert result["progress_action"] == "wait_for_bluetooth_connect" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.connect" + ) as mock_connect, patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.disconnect" + ) as mock_disconnect: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "associate" + mock_connect.assert_awaited_once() + mock_disconnect.assert_awaited_once() + + 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", + 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") + + +@pytest.mark.parametrize("exc", (BleakError, Exception)) +async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc) -> 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.SHOW_PROGRESS + assert result["step_id"] == "bluetooth_connect" + assert result["progress_action"] == "wait_for_bluetooth_connect" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.connect", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "could_not_connect" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "could_not_connect" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "bluetooth_connect" + assert result["progress_action"] == "wait_for_bluetooth_connect" + + +@pytest.mark.parametrize( + "exc, error", + ( + (dkey_errors.InvalidActivationCode, "invalid_code"), + (dkey_errors.WrongActivationCode, "wrong_code"), + (Exception, "unknown"), + ), +) +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.SHOW_PROGRESS + assert result["step_id"] == "bluetooth_connect" + assert result["progress_action"] == "wait_for_bluetooth_connect" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.connect" + ) as mock_connect: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "associate" + mock_connect.assert_awaited_once() + + 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} From 98e65826d33b19a5a6cc3b4cd6a867f0caf80c7a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Feb 2023 23:34:33 +0100 Subject: [PATCH 02/14] Pylint --- homeassistant/components/dormakaba_dkey/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 889b3eafca570..b9d94befaf560 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -93,7 +93,7 @@ async def _connect() -> None: except BleakError as err: _LOGGER.info("Could not connect to lock", exc_info=err) return self.async_show_progress_done(next_step_id="could_not_connect") - except Exception as err: + except Exception as err: # pylint: disable=broad-except _LOGGER.error("Unknown error when connecting to lock", exc_info=err) return self.async_show_progress_done(next_step_id="could_not_connect") finally: From b421c625ed572222458ab8a759eadac9b930ce51 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Feb 2023 17:00:28 +0100 Subject: [PATCH 03/14] Address review comments --- .../components/dormakaba_dkey/__init__.py | 15 +++------ .../components/dormakaba_dkey/config_flow.py | 4 +-- .../components/dormakaba_dkey/const.py | 2 ++ .../components/dormakaba_dkey/lock.py | 6 ++-- .../components/dormakaba_dkey/manifest.json | 3 -- .../components/dormakaba_dkey/models.py | 2 +- .../components/dormakaba_dkey/strings.json | 4 +-- .../dormakaba_dkey/translations/en.json | 33 ------------------- 8 files changed, 14 insertions(+), 55 deletions(-) delete mode 100644 homeassistant/components/dormakaba_dkey/translations/en.json diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 05b09862d5378..0584d6a15d0b9 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -16,10 +16,9 @@ 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.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS +from .const import ASSOCIATION_DATA, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS from .models import DormakabaDkeyData PLATFORMS: list[Platform] = [Platform.LOCK] @@ -27,12 +26,6 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Dormakaba dKey integration.""" - hass.data[DOMAIN] = {} - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Dormakaba dKey from a config entry.""" address: str = entry.data[CONF_ADDRESS] @@ -41,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Could not find dKey device with address {address}") lock = DKEYLock(ble_device) - lock.set_association_data(AssociationData.from_json(entry.data["association_data"])) + lock.set_association_data(AssociationData.from_json(entry.data[ASSOCIATION_DATA])) @callback def _async_update_ble( @@ -62,7 +55,7 @@ def _async_update_ble( ) ) - async def _async_update(): + async def _async_update() -> None: """Update the device state.""" try: await lock.update() @@ -97,7 +90,7 @@ async def _async_update(): finally: cancel_first_update() - hass.data[DOMAIN][entry.entry_id] = DormakabaDkeyData( + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( entry.title, lock, coordinator ) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index b9d94befaf560..8261c6a3116cd 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import ASSOCIATION_DATA, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -132,7 +132,7 @@ async def async_step_associate( or lock.name, data={ CONF_ADDRESS: self._discovery_info.device.address, - "association_data": association_data.to_json(), + ASSOCIATION_DATA: association_data.to_json(), }, ) diff --git a/homeassistant/components/dormakaba_dkey/const.py b/homeassistant/components/dormakaba_dkey/const.py index c4c653651e08d..32c5fee98fa59 100644 --- a/homeassistant/components/dormakaba_dkey/const.py +++ b/homeassistant/components/dormakaba_dkey/const.py @@ -4,3 +4,5 @@ DEVICE_TIMEOUT = 30 UPDATE_SECONDS = 120 + +ASSOCIATION_DATA = "association_data" diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 97677d8f0a056..dffebdd6bc867 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -28,16 +28,16 @@ async def async_setup_entry( ) -> 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, entry.title)]) + async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)]) -class DormakabaDkeyLock(CoordinatorEntity, LockEntity): +class DormakabaDkeyLock(CoordinatorEntity[DataUpdateCoordinator[None]], LockEntity): """Representation of Dormakaba dKey lock.""" _attr_has_entity_name = True def __init__( - self, coordinator: DataUpdateCoordinator, lock: DKEYLock, name: str + self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock ) -> None: """Initialize a Dormakaba dKey lock.""" super().__init__(coordinator) diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index b549c88607e0e..51f598489f304 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -13,10 +13,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", - "homekit": {}, "iot_class": "local_polling", "requirements": ["py-dormakaba-dkey==1.0.0"], - "ssdp": [], - "zeroconf": [], "integration_type": "device" } diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py index b26097fd839c1..d59044167075a 100644 --- a/homeassistant/components/dormakaba_dkey/models.py +++ b/homeassistant/components/dormakaba_dkey/models.py @@ -14,4 +14,4 @@ class DormakabaDkeyData: title: str lock: DKEYLock - coordinator: DataUpdateCoordinator + coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 12483bc9ea079..f35385c473c64 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -19,8 +19,8 @@ } }, "error": { - "invalid_code": "Invalid activaction code. An activaction code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", - "wrong_code": "Wrong activaction code. Note that an activation code can only be used once.", + "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.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "progress": { diff --git a/homeassistant/components/dormakaba_dkey/translations/en.json b/homeassistant/components/dormakaba_dkey/translations/en.json deleted file mode 100644 index 27e3aea53bf41..0000000000000 --- a/homeassistant/components/dormakaba_dkey/translations/en.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "invalid_code": "Invalid activaction code. An activaction code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", - "unknown": "Unexpected error", - "wrong_code": "Wrong activaction code. Note that an activation code can only be used once." - }, - "flow_title": "{name}", - "progress": { - "wait_for_bluetooth_connect": "Connecting..." - }, - "step": { - "associate": { - "data": { - "activation_code": "Activation code" - }, - "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." - }, - "bluetooth_confirm": { - "description": "Do you want to set up {name}?" - }, - "bluetooth_connect": { - "description": "Please wait while a connection with the lock is established" - }, - "could_not_connect": { - "description": "Could not connect to the lock, do you want to try again?" - } - } - } -} \ No newline at end of file From 6d2274575e1b07e4257e84ae25a8161b9a1a61fd Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Feb 2023 17:14:49 +0100 Subject: [PATCH 04/14] Add test for already configured entry --- .../dormakaba_dkey/test_config_flow.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 8c8bf5bca5b41..ec7e2af184416 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -14,6 +14,8 @@ from . import DKEY_DISCOVERY_INFO +from tests.common import MockConfigEntry + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" @@ -74,6 +76,20 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: 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" + + @pytest.mark.parametrize("exc", (BleakError, Exception)) async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc) -> None: """Test bluetooth step and we cannot connect.""" From bc186299c9ea5a1fb983f6678f010cf4726c7a7e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Feb 2023 18:16:26 +0100 Subject: [PATCH 05/14] Add user flow --- .../components/dormakaba_dkey/config_flow.py | 54 +++++++++++++- .../components/dormakaba_dkey/manifest.json | 2 +- .../components/dormakaba_dkey/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dormakaba_dkey/__init__.py | 24 ++++++- .../dormakaba_dkey/test_config_flow.py | 70 ++++++++++++++++++- 7 files changed, 147 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 8261c6a3116cd..2c3db28eff144 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -6,11 +6,14 @@ from typing import Any from bleak import BleakError -from py_dormakaba_dkey import DKEYLock, errors as dkey_errors +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 +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -34,8 +37,55 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._connect_task: Task | 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) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_bluetooth_connect() + + 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_unconfigured_devices") + + 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: diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 51f598489f304..9c700a0dc5a38 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -14,6 +14,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.0"], + "requirements": ["py-dormakaba-dkey==1.0.1"], "integration_type": "device" } diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index f35385c473c64..97d91409b032c 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -27,7 +27,8 @@ "wait_for_bluetooth_connect": "Connecting..." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_unconfigured_devices": "No unconfigured devices found." } } } diff --git a/requirements_all.txt b/requirements_all.txt index 97af26080089e..57c65be25323c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1433,7 +1433,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.0 +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 4be4258f06678..02f7eddf34cb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1048,7 +1048,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.0 +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 index 265b7ba8d3601..12396a8c82b3b 100644 --- a/tests/components/dormakaba_dkey/__init__.py +++ b/tests/components/dormakaba_dkey/__init__.py @@ -7,13 +7,33 @@ DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", - address="AA:BB:CC:DD:EE:FF", + 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:FF", name="00123456"), + 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/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index ec7e2af184416..68c2410f0989b 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -10,13 +10,73 @@ 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 FlowResultType +from homeassistant.data_entry_flow import FlowResult, FlowResultType -from . import DKEY_DISCOVERY_INFO +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.SHOW_PROGRESS + assert result["step_id"] == "bluetooth_connect" + assert result["progress_action"] == "wait_for_bluetooth_connect" + + 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_unconfigured_devices" + + +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.led_ble.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_unconfigured_devices" + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( @@ -38,6 +98,12 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_connect" assert result["progress_action"] == "wait_for_bluetooth_connect" + 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.connect" ) as mock_connect, patch( From 027b2d2f332758e190c0951a6b211472ecd09f90 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 9 Feb 2023 20:15:40 +0100 Subject: [PATCH 06/14] Address review comments --- homeassistant/components/dormakaba_dkey/__init__.py | 6 ++++-- homeassistant/components/dormakaba_dkey/config_flow.py | 8 ++++---- homeassistant/components/dormakaba_dkey/const.py | 2 +- homeassistant/components/dormakaba_dkey/strings.json | 8 +++++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 0584d6a15d0b9..306a5e23fe069 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ASSOCIATION_DATA, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS +from .const import CONF_ASSOCIATION_DATA, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS from .models import DormakabaDkeyData PLATFORMS: list[Platform] = [Platform.LOCK] @@ -34,7 +34,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Could not find dKey device with address {address}") lock = DKEYLock(ble_device) - lock.set_association_data(AssociationData.from_json(entry.data[ASSOCIATION_DATA])) + lock.set_association_data( + AssociationData.from_json(entry.data[CONF_ASSOCIATION_DATA]) + ) @callback def _async_update_ble( diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 2c3db28eff144..1d672176146c7 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult -from .const import ASSOCIATION_DATA, DOMAIN +from .const import CONF_ASSOCIATION_DATA, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def async_step_user( if user_input is not None: address = user_input[CONF_ADDRESS] - await self.async_set_unique_id(address) + await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() self._discovery_info = self._discovered_devices[address] return await self.async_step_bluetooth_connect() @@ -66,7 +66,7 @@ async def async_step_user( self._discovered_devices[discovery.address] = discovery if not self._discovered_devices: - return self.async_abort(reason="no_unconfigured_devices") + return self.async_abort(reason="no_devices_found") data_schema = vol.Schema( { @@ -182,7 +182,7 @@ async def async_step_associate( or lock.name, data={ CONF_ADDRESS: self._discovery_info.device.address, - ASSOCIATION_DATA: association_data.to_json(), + CONF_ASSOCIATION_DATA: association_data.to_json(), }, ) diff --git a/homeassistant/components/dormakaba_dkey/const.py b/homeassistant/components/dormakaba_dkey/const.py index 32c5fee98fa59..62392707564f3 100644 --- a/homeassistant/components/dormakaba_dkey/const.py +++ b/homeassistant/components/dormakaba_dkey/const.py @@ -5,4 +5,4 @@ DEVICE_TIMEOUT = 30 UPDATE_SECONDS = 120 -ASSOCIATION_DATA = "association_data" +CONF_ASSOCIATION_DATA = "association_data" diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 97d91409b032c..4aec66519b8ef 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -2,6 +2,12 @@ "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%]" }, @@ -28,7 +34,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_unconfigured_devices": "No unconfigured devices found." + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } } } From 8cea551a4eff3863574dd9bed28dd63bc93043ea Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 9 Feb 2023 20:37:29 +0100 Subject: [PATCH 07/14] Simplify config flow --- .../components/dormakaba_dkey/config_flow.py | 61 ++------------ .../components/dormakaba_dkey/strings.json | 16 +--- .../dormakaba_dkey/test_config_flow.py | 82 ++++++------------- 3 files changed, 37 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 1d672176146c7..1a0c9e938e8ba 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations -from asyncio import Task import logging from typing import Any @@ -32,11 +31,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Dormakaba dKey.""" VERSION = 1 - _lock: DKEYLock def __init__(self) -> None: """Initialize the config flow.""" - self._connect_task: Task | None = None + self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} # Populated by bluetooth and user steps @@ -53,7 +51,7 @@ async def async_step_user( await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() self._discovery_info = self._discovered_devices[address] - return await self.async_step_bluetooth_connect() + return await self.async_step_associate() current_addresses = self._async_current_ids() for discovery in async_discovered_service_info(self.hass): @@ -111,45 +109,7 @@ async def async_step_bluetooth_confirm( description_placeholders={"name": name}, ) - return await self.async_step_bluetooth_connect() - - async def async_step_bluetooth_connect( - 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 - - async def _connect() -> None: - try: - await self._lock.connect() - # Disconnect again so the user can use the admin app to create a new key - await self._lock.disconnect() - finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) - - if not self._connect_task: - self._lock = DKEYLock(self._discovery_info.device) - self._connect_task = self.hass.async_create_task(_connect()) - return self.async_show_progress( - step_id="bluetooth_connect", - progress_action="wait_for_bluetooth_connect", - ) - - try: - await self._connect_task - except BleakError as err: - _LOGGER.info("Could not connect to lock", exc_info=err) - return self.async_show_progress_done(next_step_id="could_not_connect") - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unknown error when connecting to lock", exc_info=err) - return self.async_show_progress_done(next_step_id="could_not_connect") - finally: - self._connect_task = None - - return self.async_show_progress_done(next_step_id="associate") + return await self.async_step_associate() async def async_step_associate( self, user_input: dict[str, Any] | None = None @@ -164,17 +124,21 @@ async def async_step_associate( ) 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") - errors["base"] = "unknown" + return self.async_abort(reason="unknown") else: return self.async_create_entry( title=lock.device_info.device_name @@ -189,12 +153,3 @@ async def async_step_associate( return self.async_show_form( step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors ) - - async def async_step_could_not_connect( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle connection failure.""" - if user_input is None: - return self.async_show_form(step_id="could_not_connect") - - return await self.async_step_bluetooth_connect() diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 4aec66519b8ef..d07deaca829eb 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,12 +11,6 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, - "bluetooth_connect": { - "description": "Please wait while a connection with the lock is established" - }, - "could_not_connect": { - "description": "Could not connect to the lock, do you want to try again?" - }, "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": { @@ -26,15 +20,13 @@ }, "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.", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "wait_for_bluetooth_connect": "Connecting..." + "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%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "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/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 68c2410f0989b..eafb66e5e058e 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -36,9 +36,9 @@ async def test_user_step_success(hass: HomeAssistant) -> None: CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, }, ) - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "bluetooth_connect" - assert result["progress_action"] == "wait_for_bluetooth_connect" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None await _test_common_success(hass, result) @@ -53,7 +53,7 @@ async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "no_unconfigured_devices" + assert result["reason"] == "no_devices_found" async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: @@ -74,7 +74,7 @@ async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "no_unconfigured_devices" + assert result["reason"] == "no_devices_found" async def test_bluetooth_step_success(hass: HomeAssistant) -> None: @@ -94,9 +94,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["errors"] is None result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "bluetooth_connect" - assert result["progress_action"] == "wait_for_bluetooth_connect" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None await _test_common_success(hass, result) @@ -104,22 +104,6 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: 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.connect" - ) as mock_connect, patch( - "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.disconnect" - ) as mock_disconnect: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "associate" - mock_connect.assert_awaited_once() - mock_disconnect.assert_awaited_once() - - 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", return_value=AssociationData(b"1234", b"AABBCCDD"), @@ -156,8 +140,14 @@ async def test_bluetooth_step_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -@pytest.mark.parametrize("exc", (BleakError, Exception)) -async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc) -> None: +@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, @@ -174,27 +164,19 @@ async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc) -> None: assert result["errors"] is None result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "bluetooth_connect" - assert result["progress_action"] == "wait_for_bluetooth_connect" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None with patch( - "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.connect", + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", side_effect=exc, ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "could_not_connect" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "could_not_connect" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "bluetooth_connect" - assert result["progress_action"] == "wait_for_bluetooth_connect" + 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( @@ -202,7 +184,6 @@ async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc) -> None: ( (dkey_errors.InvalidActivationCode, "invalid_code"), (dkey_errors.WrongActivationCode, "wrong_code"), - (Exception, "unknown"), ), ) async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) -> None: @@ -222,19 +203,6 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["errors"] is None result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "bluetooth_connect" - assert result["progress_action"] == "wait_for_bluetooth_connect" - - with patch( - "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.connect" - ) as mock_connect: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "associate" - mock_connect.assert_awaited_once() - - 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 From e130ab9701cb06cd28a1ac74a20205e220841bd7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 9 Feb 2023 21:10:42 +0100 Subject: [PATCH 08/14] Add tests --- .../components/dormakaba_dkey/config_flow.py | 1 + .../dormakaba_dkey/test_config_flow.py | 123 +++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 1a0c9e938e8ba..9131264445f32 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -140,6 +140,7 @@ async def async_step_associate( _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + self._abort_if_unique_id_configured() return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index eafb66e5e058e..a1b4887f3deeb 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -67,7 +67,7 @@ async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) with patch( - "homeassistant.components.led_ble.config_flow.async_discovered_service_info", + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", return_value=[DKEY_DISCOVERY_INFO], ): result = await hass.config_entries.flow.async_init( @@ -77,8 +77,73 @@ async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: assert result["reason"] == "no_devices_found" -async def test_bluetooth_step_success(hass: HomeAssistant) -> None: - """Test bluetooth step success path.""" +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_user_step_device_added_between_steps_2(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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": DKEY_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + return_value=AssociationData(b"1234", b"AABBCCDD"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + 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}, @@ -86,9 +151,40 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: ) 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 - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + 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 @@ -140,6 +236,25 @@ async def test_bluetooth_step_already_configured(hass: HomeAssistant) -> None: 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", ( From e4a40244a27c3b8b86072349f927de17191e5495 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 9 Feb 2023 21:33:02 +0100 Subject: [PATCH 09/14] Sort manifest --- .../components/dormakaba_dkey/manifest.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 9c700a0dc5a38..55ffaad4b3d12 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -2,18 +2,14 @@ "domain": "dormakaba_dkey", "name": "Dormakaba dKey", "bluetooth": [ - { - "service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897" - }, - { - "service_uuid": "e7a60001-6639-429f-94fd-86de8ea26897" - } + { "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"], - "integration_type": "device" + "requirements": ["py-dormakaba-dkey==1.0.1"] } From 0ca2cce96e32165d8aba025e37c949198ba7f6c3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Feb 2023 09:48:16 +0100 Subject: [PATCH 10/14] Remove useless _abort_if_unique_id_configured --- .../components/dormakaba_dkey/config_flow.py | 3 +- .../dormakaba_dkey/test_config_flow.py | 38 ------------------- 2 files changed, 2 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 9131264445f32..dca19c802b1b5 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -49,6 +49,8 @@ async def async_step_user( 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() @@ -140,7 +142,6 @@ async def async_step_associate( _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - self._abort_if_unique_id_configured() return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index a1b4887f3deeb..c983f70a0c845 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -104,44 +104,6 @@ async def test_user_step_device_added_between_steps_1(hass: HomeAssistant) -> No assert result["reason"] == "already_configured" -async def test_user_step_device_added_between_steps_2(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" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"address": DKEY_DISCOVERY_INFO.address}, - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "associate" - assert result["errors"] is None - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=DKEY_DISCOVERY_INFO.address, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", - return_value=AssociationData(b"1234", b"AABBCCDD"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"activation_code": "1234-1234"} - ) - 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( From 9a088fa8f1ca165b2642f8c6c2218520830f7682 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Feb 2023 10:07:12 +0100 Subject: [PATCH 11/14] Remove config entry update listener --- homeassistant/components/dormakaba_dkey/__init__.py | 10 +--------- homeassistant/components/dormakaba_dkey/models.py | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 306a5e23fe069..4101cfa49ae45 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -93,11 +93,10 @@ async def _async_update() -> None: cancel_first_update() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( - entry.title, lock, coordinator + lock, coordinator ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) async def _async_stop(event: Event) -> None: """Close the connection.""" @@ -109,13 +108,6 @@ async def _async_stop(event: Event) -> None: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: - await hass.config_entries.async_reload(entry.entry_id) - - 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): diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py index d59044167075a..cd260c15e81fb 100644 --- a/homeassistant/components/dormakaba_dkey/models.py +++ b/homeassistant/components/dormakaba_dkey/models.py @@ -12,6 +12,5 @@ class DormakabaDkeyData: """Data for the Dormakaba dKey integration.""" - title: str lock: DKEYLock coordinator: DataUpdateCoordinator[None] From 0ef9d1c6bb452b3a06856bc6bf5e81303a33c6b9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Feb 2023 17:09:54 +0100 Subject: [PATCH 12/14] Simplify user flow --- .../components/bluetooth/strings.json | 4 +- .../components/dormakaba_dkey/config_flow.py | 59 ++---- .../components/dormakaba_dkey/strings.json | 9 +- tests/components/dormakaba_dkey/__init__.py | 18 -- .../dormakaba_dkey/test_config_flow.py | 171 +++++------------- 5 files changed, 69 insertions(+), 192 deletions(-) diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index a988477778d97..213b077441226 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -29,7 +29,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." + "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters.", + "no_additional_devices_found": "No additional matching Bluetooth devices found", + "no_devices_found": "No matching Bluetooth devices found" } }, "options": { diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index dca19c802b1b5..6f7b3e5c08847 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -5,13 +5,13 @@ from typing import Any from bleak import BleakError -from py_dormakaba_dkey import DKEYLock, device_filter, errors as dkey_errors +from py_dormakaba_dkey import DKEYLock, errors as dkey_errors import voluptuous as vol from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, - async_discovered_service_info, + async_rediscover_address, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -43,48 +43,14 @@ def __init__(self) -> 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, - ) + """Handle the user step. + + The user will be shown a list of matching discovery flows automatically, + so we just abort a user flow. + """ + if self._async_in_progress(include_uninitialized=True): + return self.async_abort(reason="no_additional_devices_found") + return self.async_abort(reason="no_devices_found") async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -155,3 +121,8 @@ async def async_step_associate( return self.async_show_form( step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors ) + + async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult: + """Unignore an ignored bluetooth discovery flow.""" + async_rediscover_address(self.hass, user_input["unique_id"]) + return self.async_abort(reason="already_in_progress") diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca829eb..cf2460569ef6c 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -2,12 +2,6 @@ "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%]" }, @@ -25,7 +19,8 @@ "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%]", + "no_additional_devices_found": "[%key:component::bluetooth::config::abort::no_devices_found%]", + "no_devices_found": "[%key:component::bluetooth::config::abort::no_devices_found%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/tests/components/dormakaba_dkey/__init__.py b/tests/components/dormakaba_dkey/__init__.py index 12396a8c82b3b..adae3f89cc009 100644 --- a/tests/components/dormakaba_dkey/__init__.py +++ b/tests/components/dormakaba_dkey/__init__.py @@ -20,21 +20,3 @@ 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/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index c983f70a0c845..ee21161ba5bbc 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -10,134 +10,37 @@ 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 homeassistant.data_entry_flow import FlowResultType -from . import DKEY_DISCOVERY_INFO, NOT_DKEY_DISCOVERY_INFO +from . import 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( +async def test_user_step(hass: HomeAssistant) -> None: + """Test user step when there is a discovery flow.""" + 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, - }, + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "associate" - assert result["errors"] is None + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_additional_devices_found" + return - await _test_common_success(hass, result) - # Verify the discovery flow was aborted - assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_step_no_discovery_flow(hass: HomeAssistant) -> None: + """Test user step with no discovery flows.""" + 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" + return async def test_bluetooth_step_success(hass: HomeAssistant) -> None: @@ -156,12 +59,6 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: 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"), @@ -294,3 +191,33 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "associate" assert result["errors"] == {"base": error} + + +async def test_unignore_flow(hass: HomeAssistant) -> None: + """Test a config flow started by unignoring a device.""" + # Create ignored entry + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={ + "unique_id": DKEY_DISCOVERY_INFO.address, + "title": DKEY_DISCOVERY_INFO.name, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == DKEY_DISCOVERY_INFO.address + + # Unignore and expect rediscover call to bluetooth + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_rediscover_address", + ) as rediscover_mock: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_UNIGNORE}, + data={"unique_id": DKEY_DISCOVERY_INFO.address}, + ) + rediscover_mock.assert_called_once_with(hass, DKEY_DISCOVERY_INFO.address) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" From 65781a417cb30082e80b3be1a0d4cce897e89ed1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Feb 2023 17:12:36 +0100 Subject: [PATCH 13/14] Remove startup event --- .../components/dormakaba_dkey/__init__.py | 24 ++----------------- .../components/dormakaba_dkey/const.py | 1 - 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 4101cfa49ae45..8162fb68bb749 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -1,11 +1,9 @@ """The Dormakaba dKey integration.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging -import async_timeout from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS from py_dormakaba_dkey.models import AssociationData @@ -18,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ASSOCIATION_DATA, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS +from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS from .models import DormakabaDkeyData PLATFORMS: list[Platform] = [Platform.LOCK] @@ -65,8 +63,6 @@ async def _async_update() -> None: except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex - startup_event = asyncio.Event() - cancel_first_update = lock.register_callback(lambda *_: startup_event.set()) coordinator = DataUpdateCoordinator( hass, _LOGGER, @@ -74,23 +70,7 @@ async def _async_update() -> None: update_method=_async_update, update_interval=timedelta(seconds=UPDATE_SECONDS), ) - - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady: - cancel_first_update() - raise - - try: - async with async_timeout.timeout(DEVICE_TIMEOUT): - await startup_event.wait() - except asyncio.TimeoutError as ex: - raise ConfigEntryNotReady( - "Unable to communicate with the device; " - f"Try moving the Bluetooth adapter closer to {lock.name}" - ) from ex - finally: - cancel_first_update() + await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( lock, coordinator diff --git a/homeassistant/components/dormakaba_dkey/const.py b/homeassistant/components/dormakaba_dkey/const.py index 62392707564f3..8d93fefbdada4 100644 --- a/homeassistant/components/dormakaba_dkey/const.py +++ b/homeassistant/components/dormakaba_dkey/const.py @@ -2,7 +2,6 @@ DOMAIN = "dormakaba_dkey" -DEVICE_TIMEOUT = 30 UPDATE_SECONDS = 120 CONF_ASSOCIATION_DATA = "association_data" From cb4fb53d431e8b9602685e5bdabbbe4f56d9c3db Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Feb 2023 12:02:53 +0100 Subject: [PATCH 14/14] Revert "Simplify user flow" This reverts commit 0ef9d1c6bb452b3a06856bc6bf5e81303a33c6b9. --- .../components/bluetooth/strings.json | 4 +- .../components/dormakaba_dkey/config_flow.py | 59 ++++-- .../components/dormakaba_dkey/strings.json | 9 +- tests/components/dormakaba_dkey/__init__.py | 18 ++ .../dormakaba_dkey/test_config_flow.py | 171 +++++++++++++----- 5 files changed, 192 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 213b077441226..a988477778d97 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -29,9 +29,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters.", - "no_additional_devices_found": "No additional matching Bluetooth devices found", - "no_devices_found": "No matching Bluetooth devices found" + "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." } }, "options": { diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 6f7b3e5c08847..dca19c802b1b5 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -5,13 +5,13 @@ from typing import Any from bleak import BleakError -from py_dormakaba_dkey import DKEYLock, errors as dkey_errors +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_rediscover_address, + async_discovered_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -43,14 +43,48 @@ def __init__(self) -> None: async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the user step. - - The user will be shown a list of matching discovery flows automatically, - so we just abort a user flow. - """ - if self._async_in_progress(include_uninitialized=True): - return self.async_abort(reason="no_additional_devices_found") - return self.async_abort(reason="no_devices_found") + """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 @@ -121,8 +155,3 @@ async def async_step_associate( return self.async_show_form( step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors ) - - async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult: - """Unignore an ignored bluetooth discovery flow.""" - async_rediscover_address(self.hass, user_input["unique_id"]) - return self.async_abort(reason="already_in_progress") diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index cf2460569ef6c..d07deaca829eb 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -2,6 +2,12 @@ "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%]" }, @@ -19,8 +25,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_additional_devices_found": "[%key:component::bluetooth::config::abort::no_devices_found%]", - "no_devices_found": "[%key:component::bluetooth::config::abort::no_devices_found%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/tests/components/dormakaba_dkey/__init__.py b/tests/components/dormakaba_dkey/__init__.py index adae3f89cc009..12396a8c82b3b 100644 --- a/tests/components/dormakaba_dkey/__init__.py +++ b/tests/components/dormakaba_dkey/__init__.py @@ -20,3 +20,21 @@ 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/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index ee21161ba5bbc..c983f70a0c845 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -10,37 +10,134 @@ 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 FlowResultType +from homeassistant.data_entry_flow import FlowResult, FlowResultType -from . import DKEY_DISCOVERY_INFO +from . import DKEY_DISCOVERY_INFO, NOT_DKEY_DISCOVERY_INFO from tests.common import MockConfigEntry -async def test_user_step(hass: HomeAssistant) -> None: - """Test user step when there is a discovery flow.""" - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=DKEY_DISCOVERY_INFO, - ) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +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_additional_devices_found" - return + assert result["reason"] == "no_devices_found" -async def test_user_step_no_discovery_flow(hass: HomeAssistant) -> None: - """Test user step with no discovery flows.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +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" - return + + +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: @@ -59,6 +156,12 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: 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"), @@ -191,33 +294,3 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "associate" assert result["errors"] == {"base": error} - - -async def test_unignore_flow(hass: HomeAssistant) -> None: - """Test a config flow started by unignoring a device.""" - # Create ignored entry - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IGNORE}, - data={ - "unique_id": DKEY_DISCOVERY_INFO.address, - "title": DKEY_DISCOVERY_INFO.name, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["context"]["unique_id"] == DKEY_DISCOVERY_INFO.address - - # Unignore and expect rediscover call to bluetooth - with patch( - "homeassistant.components.dormakaba_dkey.config_flow.async_rediscover_address", - ) as rediscover_mock: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_UNIGNORE}, - data={"unique_id": DKEY_DISCOVERY_INFO.address}, - ) - rediscover_mock.assert_called_once_with(hass, DKEY_DISCOVERY_INFO.address) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_in_progress"