diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 3c4c08b389d..faa908f1e17 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.6", "yalexs_ble==1.12.12"] + "requirements": ["yalexs==1.2.6", "yalexs_ble==2.0.0"] } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index fcecec19e6b..c2bde758668 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,16 +1,13 @@ """The Yale Access Bluetooth integration.""" from __future__ import annotations -import asyncio - -import async_timeout -from yalexs_ble import PushLock, local_name_is_unique +from yalexs_ble import AuthError, PushLock, YaleXSBLEError, local_name_is_unique from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN from .models import YaleXSBLEData @@ -30,8 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") - startup_event = asyncio.Event() - @callback def _async_update_ble( service_info: bluetooth.BluetoothServiceInfoBleak, @@ -40,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Update from a ble callback.""" push_lock.update_advertisement(service_info.device, service_info.advertisement) - cancel_first_update = push_lock.register_callback(lambda *_: startup_event.set()) entry.async_on_unload(await push_lock.start()) # We may already have the advertisement, so check for it. @@ -57,15 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - async with async_timeout.timeout(DEVICE_TIMEOUT): - await startup_event.wait() - except asyncio.TimeoutError as ex: + await push_lock.wait_for_first_update(DEVICE_TIMEOUT) + except AuthError as ex: + raise ConfigEntryAuthFailed(str(ex)) from ex + except YaleXSBLEError as ex: raise ConfigEntryNotReady( - f"{push_lock.last_error}; " - f"Try moving the Bluetooth adapter closer to {local_name}" + f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex - finally: - cancel_first_update() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData( entry.title, push_lock diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index e6008526341..9739ca546ac 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Yale Access Bluetooth integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -18,11 +19,11 @@ from yalexs_ble.const import YALE_MFR_ID from homeassistant import config_entries, data_entry_flow from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, + async_ble_device_from_address, async_discovered_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN @@ -31,19 +32,28 @@ from .util import async_find_existing_service_info, human_readable_name _LOGGER = logging.getLogger(__name__) -async def validate_lock( - local_name: str, device: BLEDevice, key: str, slot: int -) -> None: - """Validate a lock.""" +async def async_validate_lock_or_error( + local_name: str, device: BLEDevice, key: str, slot: str +) -> dict[str, str]: + """Validate the lock and return errors if any.""" if len(key) != 32: - raise InvalidKeyFormat + return {CONF_KEY: "invalid_key_format"} try: bytes.fromhex(key) - except ValueError as ex: - raise InvalidKeyFormat from ex + except ValueError: + return {CONF_KEY: "invalid_key_format"} if not isinstance(slot, int) or slot < 0 or slot > 255: - raise InvalidKeyIndex - await PushLock(local_name, device.address, device, key, slot).validate() + return {CONF_SLOT: "invalid_key_index"} + try: + await PushLock(local_name, device.address, device, key, slot).validate() + except (DisconnectedError, AuthError, ValueError): + return {CONF_KEY: "invalid_auth"} + except BleakError: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + return {"base": "unknown"} + return {} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -56,6 +66,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} self._lock_cfg: ValidatedLockConfig | None = None + self._reauth_entry: config_entries.ConfigEntry | None = None async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -166,6 +177,51 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_validate() + + async def async_step_reauth_validate(self, user_input=None): + """Handle reauth and validation.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + if user_input is not None: + if ( + device := async_ble_device_from_address( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + elif not ( + errors := await async_validate_lock_or_error( + reauth_entry.data[CONF_LOCAL_NAME], + device, + user_input[CONF_KEY], + user_input[CONF_SLOT], + ) + ): + self.hass.config_entries.async_update_entry( + self._reauth_entry, data={**reauth_entry.data, **user_input} + ) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_validate", + data_schema=vol.Schema( + {vol.Required(CONF_KEY): str, vol.Required(CONF_SLOT): int} + ), + description_placeholders={ + "address": reauth_entry.data[CONF_ADDRESS], + "title": reauth_entry.title, + }, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -183,20 +239,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): discovery_info.address, raise_on_progress=False ) self._abort_if_unique_id_configured() - try: - await validate_lock(local_name, discovery_info.device, key, slot) - except InvalidKeyFormat: - errors[CONF_KEY] = "invalid_key_format" - except InvalidKeyIndex: - errors[CONF_SLOT] = "invalid_key_index" - except (DisconnectedError, AuthError, ValueError): - errors[CONF_KEY] = "invalid_auth" - except BleakError: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: + if not ( + errors := await async_validate_lock_or_error( + local_name, discovery_info.device, key, slot + ) + ): return self.async_create_entry( title=local_name, data={ @@ -248,11 +295,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) - - -class InvalidKeyFormat(HomeAssistantError): - """Invalid key format.""" - - -class InvalidKeyIndex(HomeAssistantError): - """Invalid key index.""" diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index f853da1d959..73d79499b65 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==1.12.12"] + "requirements": ["yalexs-ble==2.0.0"] } diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 6a60982f29c..9a83bca29fb 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -3,18 +3,26 @@ "flow_title": "{name}", "step": { "user": { - "description": "Check the documentation for how to find the offline key.", + "description": "Check the documentation for how to find the offline key. If you are using the August cloud integration to obtain the key, you may need to reload the August cloud integration while the lock is in Bluetooth range.", "data": { "address": "Bluetooth address", "key": "Offline Key (32-byte hex string)", "slot": "Offline Key Slot (Integer between 0 and 255)" } }, + "reauth_validate": { + "description": "Enter the updated key for the {title} lock with address {address}. If you are using the August cloud integration to obtain the key, you may be able to avoid manual reauthentication by reloading the August cloud integration while the lock is in Bluetooth range.", + "data": { + "key": "[%key:component::yalexs_ble::config::step::user::data::key%]", + "slot": "[%key:component::yalexs_ble::config::step::user::data::slot%]" + } + }, "integration_discovery_confirm": { "description": "Do you want to set up {name} over Bluetooth with address {address}?" } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", @@ -25,7 +33,8 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "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%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 884228312a2..196ef8442a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2664,13 +2664,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.12.12 +yalexs-ble==2.0.0 # homeassistant.components.august yalexs==1.2.6 # homeassistant.components.august -yalexs_ble==1.12.12 +yalexs_ble==2.0.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1c6e93cdee..7fe26a8188a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,13 +1886,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.12.12 +yalexs-ble==2.0.0 # homeassistant.components.august yalexs==1.2.6 # homeassistant.components.august -yalexs_ble==1.12.12 +yalexs_ble==2.0.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 7f9c1fdf948..b0a4dcf8d59 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -884,3 +884,64 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle( user_flow_result["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address ) assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_validate" + + with patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + side_effect=RuntimeError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reauth_validate" + assert result2["errors"] == {"base": "no_longer_in_range"} + + with patch( + "homeassistant.components.yalexs_ble.config_flow.async_ble_device_from_address", + return_value=YALE_ACCESS_LOCK_DISCOVERY_INFO, + ), patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + ), patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1