Fix Yale Access Bluetooth key discovery timing issues (#151433)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-09-01 10:34:52 +00:00
committed by Franck Nijhof
co-authored by Copilot
parent fbab53bd0c
commit d00bf4b014
5 changed files with 426 additions and 137 deletions
@@ -19,6 +19,7 @@ from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .config_cache import async_get_validated_config
from .const import (
CONF_ALWAYS_CONNECTED,
CONF_KEY,
@@ -96,13 +97,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) ->
)
try:
await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
except AuthError as ex:
raise ConfigEntryAuthFailed(str(ex)) from ex
except (YaleXSBLEError, TimeoutError) as ex:
raise ConfigEntryNotReady(
f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
) from ex
await _async_wait_for_first_update(push_lock, local_name)
except ConfigEntryAuthFailed:
# If key has rotated, try to fetch it from the cache
# and update
if (validated_config := async_get_validated_config(hass, address)) and (
validated_config.key != entry.data[CONF_KEY]
or validated_config.slot != entry.data[CONF_SLOT]
):
assert shutdown_callback is not None
shutdown_callback()
push_lock.set_lock_key(validated_config.key, validated_config.slot)
shutdown_callback = await push_lock.start()
await _async_wait_for_first_update(push_lock, local_name)
# If we can use the cached key and slot, update the entry.
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_KEY: validated_config.key,
CONF_SLOT: validated_config.slot,
},
)
else:
raise
entry.runtime_data = YaleXSBLEData(entry.title, push_lock, always_connected)
@@ -147,6 +165,18 @@ async def _async_update_listener(
await hass.config_entries.async_reload(entry.entry_id)
async def _async_wait_for_first_update(push_lock: PushLock, local_name: str) -> None:
"""Wait for the first update from the push lock."""
try:
await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
except AuthError as ex:
raise ConfigEntryAuthFailed(str(ex)) from ex
except (YaleXSBLEError, TimeoutError) as ex:
raise ConfigEntryNotReady(
f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
) from ex
async def async_unload_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,31 @@
"""The Yale Access Bluetooth integration."""
from __future__ import annotations
from yalexs_ble import ValidatedLockConfig
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.hass_dict import HassKey
CONFIG_CACHE: HassKey[dict[str, ValidatedLockConfig]] = HassKey(
"yalexs_ble_config_cache"
)
@callback
def async_add_validated_config(
hass: HomeAssistant,
address: str,
config: ValidatedLockConfig,
) -> None:
"""Add a validated config."""
hass.data.setdefault(CONFIG_CACHE, {})[address] = config
@callback
def async_get_validated_config(
hass: HomeAssistant,
address: str,
) -> ValidatedLockConfig | None:
"""Get the config for a specific address."""
return hass.data.get(CONFIG_CACHE, {}).get(address)
@@ -33,6 +33,7 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.typing import DiscoveryInfoType
from .config_cache import async_add_validated_config, async_get_validated_config
from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
from .util import async_find_existing_service_info, human_readable_name
@@ -92,7 +93,10 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
None, discovery_info.name, discovery_info.address
),
}
return await self.async_step_user()
if lock_cfg := async_get_validated_config(self.hass, discovery_info.address):
self._lock_cfg = lock_cfg
return await self.async_step_integration_discovery_confirm()
return await self.async_step_key_slot()
async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
@@ -105,6 +109,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info["key"],
discovery_info["slot"],
)
async_add_validated_config(self.hass, lock_cfg.address, lock_cfg)
address = lock_cfg.address
self.local_name = lock_cfg.local_name
@@ -232,6 +237,59 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_key_slot(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the key and slot step."""
errors: dict[str, str] = {}
discovery_info = self._discovery_info
assert discovery_info is not None
address = discovery_info.address
validated_config = async_get_validated_config(self.hass, address)
if user_input is not None or validated_config:
local_name = discovery_info.name
if validated_config:
key = validated_config.key
slot = validated_config.slot
title = validated_config.name
else:
assert user_input is not None
key = user_input[CONF_KEY]
slot = user_input[CONF_SLOT]
title = human_readable_name(None, local_name, address)
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
if not (
errors := await async_validate_lock_or_error(
local_name, discovery_info.device, key, slot
)
):
return self.async_create_entry(
title=title,
data={
CONF_LOCAL_NAME: discovery_info.name,
CONF_ADDRESS: discovery_info.address,
CONF_KEY: key,
CONF_SLOT: slot,
},
)
return self.async_show_form(
step_id="key_slot",
data_schema=vol.Schema(
{
vol.Required(CONF_KEY): str,
vol.Required(CONF_SLOT): int,
}
),
errors=errors,
description_placeholders={
"address": address,
"title": self._async_get_name_from_address(address),
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -241,47 +299,24 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self.active = True
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
local_name = discovery_info.name
key = user_input[CONF_KEY]
slot = user_input[CONF_SLOT]
await self.async_set_unique_id(
discovery_info.address, raise_on_progress=False
)
self._abort_if_unique_id_configured()
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={
CONF_LOCAL_NAME: discovery_info.name,
CONF_ADDRESS: discovery_info.address,
CONF_KEY: key,
CONF_SLOT: slot,
},
)
self._discovery_info = self._discovered_devices[address]
return await self.async_step_key_slot()
if discovery := self._discovery_info:
current_addresses = self._async_current_ids(include_ignore=False)
current_unique_names = {
entry.data.get(CONF_LOCAL_NAME)
for entry in self._async_current_entries()
if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
}
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.name in current_unique_names
or discovery.address in self._discovered_devices
or YALE_MFR_ID not in discovery.manufacturer_data
):
continue
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
current_unique_names = {
entry.data.get(CONF_LOCAL_NAME)
for entry in self._async_current_entries()
if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
}
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.name in current_unique_names
or discovery.address in self._discovered_devices
or YALE_MFR_ID not in discovery.manufacturer_data
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
@@ -290,14 +325,12 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
service_info.address: self._async_get_name_from_address(
service_info.address
)
for service_info in self._discovered_devices.values()
}
),
vol.Required(CONF_KEY): str,
vol.Required(CONF_SLOT): int,
)
}
)
return self.async_show_form(
@@ -306,6 +339,18 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
@callback
def _async_get_name_from_address(self, address: str) -> str:
"""Get the name of a device from its address."""
if validated_config := async_get_validated_config(self.hass, address):
return f"{validated_config.name} ({address})"
if address in self._discovered_devices:
service_info = self._discovered_devices[address]
return f"{service_info.name} ({service_info.address})"
assert self._discovery_info is not None
assert self._discovery_info.address == address
return f"{self._discovery_info.name} ({address})"
@staticmethod
@callback
def async_get_options_flow(
@@ -3,18 +3,23 @@
"flow_title": "{name}",
"step": {
"user": {
"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.",
"description": "Select the device you want to set up over Bluetooth.",
"data": {
"address": "Bluetooth address"
}
},
"key_slot": {
"description": "Enter the key for the {title} lock with address {address}. If you are using the August or Yale cloud integration to obtain the key, you may be able to avoid this manual setup by reloading the August or Yale 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.",
"description": "Enter the updated key for the {title} lock with address {address}. If you are using the August or Yale cloud integration to obtain the key, you may be able to avoid manual re-authentication by reloading the August or Yale 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%]"
"key": "[%key:component::yalexs_ble::config::step::key_slot::data::key%]",
"slot": "[%key:component::yalexs_ble::config::step::key_slot::data::slot%]"
}
},
"integration_discovery_confirm": {
+258 -80
View File
@@ -61,6 +61,16 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
with (
patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
@@ -70,25 +80,24 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None:
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: slot,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result2["data"] == {
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result3["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: slot,
}
assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -113,6 +122,16 @@ async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
with (
patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
@@ -122,25 +141,24 @@ async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None:
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: slot,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result2["data"] == {
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result3["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: slot,
}
assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -198,37 +216,44 @@ async def test_user_step_invalid_keys(hass: HomeAssistant) -> None:
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "dog",
CONF_SLOT: 66,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {CONF_KEY: "invalid_key_format"}
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "qfd51b8621c6a139eaffbedcb846b60f",
CONF_KEY: "dog",
CONF_SLOT: 66,
},
)
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "user"
assert result3["step_id"] == "key_slot"
assert result3["errors"] == {CONF_KEY: "invalid_key_format"}
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "qfd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
assert result4["type"] is FlowResultType.FORM
assert result4["step_id"] == "key_slot"
assert result4["errors"] == {CONF_KEY: "invalid_key_format"}
result5 = await hass.config_entries.flow.async_configure(
result4["flow_id"],
{
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 999,
},
)
assert result4["type"] is FlowResultType.FORM
assert result4["step_id"] == "user"
assert result4["errors"] == {CONF_SLOT: "invalid_key_index"}
assert result5["type"] is FlowResultType.FORM
assert result5["step_id"] == "key_slot"
assert result5["errors"] == {CONF_SLOT: "invalid_key_index"}
with (
patch(
@@ -239,25 +264,24 @@ async def test_user_step_invalid_keys(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
result5 = await hass.config_entries.flow.async_configure(
result4["flow_id"],
result6 = await hass.config_entries.flow.async_configure(
result5["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result5["type"] is FlowResultType.CREATE_ENTRY
assert result5["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result5["data"] == {
assert result6["type"] is FlowResultType.CREATE_ENTRY
assert result6["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result6["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
assert result5["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result6["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -274,23 +298,32 @@ async def test_user_step_cannot_connect(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
with patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
side_effect=BleakError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {"base": "cannot_connect"}
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "key_slot"
assert result3["errors"] == {"base": "cannot_connect"}
with (
patch(
@@ -301,25 +334,24 @@ async def test_user_step_cannot_connect(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result3["data"] == {
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result4["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -336,23 +368,32 @@ async def test_user_step_auth_exception(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
with patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
side_effect=AuthError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {CONF_KEY: "invalid_auth"}
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "key_slot"
assert result3["errors"] == {CONF_KEY: "invalid_auth"}
with (
patch(
@@ -363,25 +404,24 @@ async def test_user_step_auth_exception(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result3["data"] == {
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result4["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -398,23 +438,32 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
assert result2["errors"] == {}
with patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
side_effect=RuntimeError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {"base": "unknown"}
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "key_slot"
assert result3["errors"] == {"base": "unknown"}
with (
patch(
@@ -425,25 +474,24 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result3["data"] == {
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result4["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
@@ -455,7 +503,7 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
data=YALE_ACCESS_LOCK_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "key_slot"
assert result["errors"] == {}
with (
@@ -470,7 +518,6 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
@@ -478,7 +525,7 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert result2["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert result2["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
@@ -563,7 +610,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth(
data=YALE_ACCESS_LOCK_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "key_slot"
assert result["errors"] == {}
flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN])
assert len(flows) == 1
@@ -629,6 +676,60 @@ async def test_integration_discovery_takes_precedence_over_bluetooth(
assert len(flows) == 0
async def test_bluetooth_discovery_with_cached_config(
hass: HomeAssistant,
) -> None:
"""Test bluetooth discovery when validated config is already in cache."""
# First, populate the cache via integration discovery
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
"name": "Front Door",
"address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
"key": "2fd51b8621c6a139eaffbedcb846b60f",
"slot": 66,
"serial": "M1XXX012LU",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
# Now do bluetooth discovery with the cached config
with patch(
"homeassistant.components.yalexs_ble.PushLock.validate",
return_value=None,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=YALE_ACCESS_LOCK_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "integration_discovery_confirm"
assert result["description_placeholders"] == {
"name": "Front Door",
"address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
}
# Confirm the discovery
with patch(
"homeassistant.components.yalexs_ble.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Front Door"
assert result["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
async def test_integration_discovery_updates_key_unique_local_name(
hass: HomeAssistant,
) -> None:
@@ -774,7 +875,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth_uuid_addres
data=LOCK_DISCOVERY_INFO_UUID_ADDRESS,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "key_slot"
assert result["errors"] == {}
flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN])
assert len(flows) == 1
@@ -850,7 +951,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth_non_unique_
data=OLD_FIRMWARE_LOCK_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "key_slot"
assert result["errors"] == {}
flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN])
assert len(flows) == 1
@@ -907,6 +1008,15 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle(
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
user_flow_event = asyncio.Event()
valdidate_started = asyncio.Event()
@@ -926,9 +1036,8 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle(
):
user_flow_task = asyncio.create_task(
hass.config_entries.flow.async_configure(
result["flow_id"],
result2["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
@@ -959,7 +1068,7 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle(
user_flow_result = await user_flow_task
assert user_flow_result["type"] is FlowResultType.CREATE_ENTRY
assert user_flow_result["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name
assert user_flow_result["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)"
assert user_flow_result["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
@@ -1033,6 +1142,75 @@ async def test_reauth(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_step_with_cached_config(hass: HomeAssistant) -> None:
"""Test user step when config is already cached from integration discovery."""
# First, simulate integration discovery to populate the cache
discovery_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
"name": "Front Door",
"address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
"key": "2fd51b8621c6a139eaffbedcb846b60f",
"slot": 66,
"serial": "M1XXX012LU",
},
)
assert discovery_result["type"] is FlowResultType.ABORT
assert discovery_result["reason"] == "no_devices_found"
# Now start a user flow - it should use the cached config
with patch(
"homeassistant.components.yalexs_ble.config_flow.async_discovered_service_info",
return_value=[YALE_ACCESS_LOCK_DISCOVERY_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# The dropdown should show "Front Door (AA:BB:CC:DD:EE:FF)" from cached config
# This is the line 346 case we're testing
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "key_slot"
# The key_slot step should auto-complete with cached values
# When no user input is provided, it should use the cached config
with (
patch(
"homeassistant.components.yalexs_ble.config_flow.PushLock.validate",
),
patch(
"homeassistant.components.yalexs_ble.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
# No user input triggers using cached config
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
None, # None triggers checking for cached config
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "Front Door" # Uses the name from cached config
assert result3["data"] == {
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
}
assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1
async def test_options(hass: HomeAssistant) -> None:
"""Test options."""
entry = MockConfigEntry(