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:
parent
509de02044
commit
84d14cc76a
8 changed files with 156 additions and 55 deletions
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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."""
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue