Add reauthentication support to Yale Access Bluetooth (#87719)

* Bump yalexs_ble to 1.12.11 to fix reconnect when services fail to resolve

changelog: https://github.com/bdraco/yalexs-ble/compare/v1.12.8...v1.12.11

* bump to make it work with esphome proxy as well

* empty

* Add reauth support to yalexs_ble

* lint

* reduce

* tweak

* tweak

* test for reauth

* Apply suggestions from code review

* cleanup
This commit is contained in:
J. Nick Koston 2023-02-09 15:24:14 -06:00 committed by GitHub
parent 509de02044
commit 84d14cc76a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 156 additions and 55 deletions

View File

@ -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"]
}

View File

@ -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

View File

@ -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."""

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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