Skip to content

Commit edc48e0

Browse files
bdracoCopilot
andauthored
Fix Yale Access Bluetooth key discovery timing issues (#151433)
Co-authored-by: Copilot <[email protected]>
1 parent eab77f1 commit edc48e0

File tree

5 files changed

+416
-127
lines changed

5 files changed

+416
-127
lines changed

homeassistant/components/yalexs_ble/__init__.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
2020
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
2121

22+
from .config_cache import async_get_validated_config
2223
from .const import (
2324
CONF_ALWAYS_CONNECTED,
2425
CONF_KEY,
@@ -96,13 +97,30 @@ def _async_shutdown(event: Event | None = None) -> None:
9697
)
9798

9899
try:
99-
await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
100-
except AuthError as ex:
101-
raise ConfigEntryAuthFailed(str(ex)) from ex
102-
except (YaleXSBLEError, TimeoutError) as ex:
103-
raise ConfigEntryNotReady(
104-
f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
105-
) from ex
100+
await _async_wait_for_first_update(push_lock, local_name)
101+
except ConfigEntryAuthFailed:
102+
# If key has rotated, try to fetch it from the cache
103+
# and update
104+
if (validated_config := async_get_validated_config(hass, address)) and (
105+
validated_config.key != entry.data[CONF_KEY]
106+
or validated_config.slot != entry.data[CONF_SLOT]
107+
):
108+
assert shutdown_callback is not None
109+
shutdown_callback()
110+
push_lock.set_lock_key(validated_config.key, validated_config.slot)
111+
shutdown_callback = await push_lock.start()
112+
await _async_wait_for_first_update(push_lock, local_name)
113+
# If we can use the cached key and slot, update the entry.
114+
hass.config_entries.async_update_entry(
115+
entry,
116+
data={
117+
**entry.data,
118+
CONF_KEY: validated_config.key,
119+
CONF_SLOT: validated_config.slot,
120+
},
121+
)
122+
else:
123+
raise
106124

107125
entry.runtime_data = YaleXSBLEData(entry.title, push_lock, always_connected)
108126

@@ -135,6 +153,18 @@ def _async_state_changed(
135153
return True
136154

137155

156+
async def _async_wait_for_first_update(push_lock: PushLock, local_name: str) -> None:
157+
"""Wait for the first update from the push lock."""
158+
try:
159+
await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
160+
except AuthError as ex:
161+
raise ConfigEntryAuthFailed(str(ex)) from ex
162+
except (YaleXSBLEError, TimeoutError) as ex:
163+
raise ConfigEntryNotReady(
164+
f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
165+
) from ex
166+
167+
138168
async def async_unload_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool:
139169
"""Unload a config entry."""
140170
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""The Yale Access Bluetooth integration."""
2+
3+
from __future__ import annotations
4+
5+
from yalexs_ble import ValidatedLockConfig
6+
7+
from homeassistant.core import HomeAssistant, callback
8+
from homeassistant.util.hass_dict import HassKey
9+
10+
CONFIG_CACHE: HassKey[dict[str, ValidatedLockConfig]] = HassKey(
11+
"yalexs_ble_config_cache"
12+
)
13+
14+
15+
@callback
16+
def async_add_validated_config(
17+
hass: HomeAssistant,
18+
address: str,
19+
config: ValidatedLockConfig,
20+
) -> None:
21+
"""Add a validated config."""
22+
hass.data.setdefault(CONFIG_CACHE, {})[address] = config
23+
24+
25+
@callback
26+
def async_get_validated_config(
27+
hass: HomeAssistant,
28+
address: str,
29+
) -> ValidatedLockConfig | None:
30+
"""Get the config for a specific address."""
31+
return hass.data.get(CONFIG_CACHE, {}).get(address)

homeassistant/components/yalexs_ble/config_flow.py

Lines changed: 80 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from homeassistant.data_entry_flow import AbortFlow
3434
from homeassistant.helpers.typing import DiscoveryInfoType
3535

36+
from .config_cache import async_add_validated_config, async_get_validated_config
3637
from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
3738
from .util import async_find_existing_service_info, human_readable_name
3839

@@ -92,7 +93,10 @@ async def async_step_bluetooth(
9293
None, discovery_info.name, discovery_info.address
9394
),
9495
}
95-
return await self.async_step_user()
96+
if lock_cfg := async_get_validated_config(self.hass, discovery_info.address):
97+
self._lock_cfg = lock_cfg
98+
return await self.async_step_integration_discovery_confirm()
99+
return await self.async_step_key_slot()
96100

97101
async def async_step_integration_discovery(
98102
self, discovery_info: DiscoveryInfoType
@@ -105,6 +109,7 @@ async def async_step_integration_discovery(
105109
discovery_info["key"],
106110
discovery_info["slot"],
107111
)
112+
async_add_validated_config(self.hass, lock_cfg.address, lock_cfg)
108113

109114
address = lock_cfg.address
110115
self.local_name = lock_cfg.local_name
@@ -232,30 +237,36 @@ async def async_step_reauth_validate(
232237
errors=errors,
233238
)
234239

235-
async def async_step_user(
240+
async def async_step_key_slot(
236241
self, user_input: dict[str, Any] | None = None
237242
) -> ConfigFlowResult:
238-
"""Handle the user step to pick discovered device."""
243+
"""Handle the key and slot step."""
239244
errors: dict[str, str] = {}
245+
discovery_info = self._discovery_info
246+
assert discovery_info is not None
247+
address = discovery_info.address
248+
validated_config = async_get_validated_config(self.hass, address)
240249

241-
if user_input is not None:
242-
self.active = True
243-
address = user_input[CONF_ADDRESS]
244-
discovery_info = self._discovered_devices[address]
250+
if user_input is not None or validated_config:
245251
local_name = discovery_info.name
246-
key = user_input[CONF_KEY]
247-
slot = user_input[CONF_SLOT]
248-
await self.async_set_unique_id(
249-
discovery_info.address, raise_on_progress=False
250-
)
252+
if validated_config:
253+
key = validated_config.key
254+
slot = validated_config.slot
255+
title = validated_config.name
256+
else:
257+
assert user_input is not None
258+
key = user_input[CONF_KEY]
259+
slot = user_input[CONF_SLOT]
260+
title = human_readable_name(None, local_name, address)
261+
await self.async_set_unique_id(address, raise_on_progress=False)
251262
self._abort_if_unique_id_configured()
252263
if not (
253264
errors := await async_validate_lock_or_error(
254265
local_name, discovery_info.device, key, slot
255266
)
256267
):
257268
return self.async_create_entry(
258-
title=local_name,
269+
title=title,
259270
data={
260271
CONF_LOCAL_NAME: discovery_info.name,
261272
CONF_ADDRESS: discovery_info.address,
@@ -264,24 +275,48 @@ async def async_step_user(
264275
},
265276
)
266277

267-
if discovery := self._discovery_info:
278+
return self.async_show_form(
279+
step_id="key_slot",
280+
data_schema=vol.Schema(
281+
{
282+
vol.Required(CONF_KEY): str,
283+
vol.Required(CONF_SLOT): int,
284+
}
285+
),
286+
errors=errors,
287+
description_placeholders={
288+
"address": address,
289+
"title": self._async_get_name_from_address(address),
290+
},
291+
)
292+
293+
async def async_step_user(
294+
self, user_input: dict[str, Any] | None = None
295+
) -> ConfigFlowResult:
296+
"""Handle the user step to pick discovered device."""
297+
errors: dict[str, str] = {}
298+
299+
if user_input is not None:
300+
self.active = True
301+
address = user_input[CONF_ADDRESS]
302+
self._discovery_info = self._discovered_devices[address]
303+
return await self.async_step_key_slot()
304+
305+
current_addresses = self._async_current_ids(include_ignore=False)
306+
current_unique_names = {
307+
entry.data.get(CONF_LOCAL_NAME)
308+
for entry in self._async_current_entries()
309+
if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
310+
}
311+
for discovery in async_discovered_service_info(self.hass):
312+
if (
313+
discovery.address in current_addresses
314+
or discovery.name in current_unique_names
315+
or discovery.address in self._discovered_devices
316+
or YALE_MFR_ID not in discovery.manufacturer_data
317+
):
318+
continue
268319
self._discovered_devices[discovery.address] = discovery
269-
else:
270-
current_addresses = self._async_current_ids(include_ignore=False)
271-
current_unique_names = {
272-
entry.data.get(CONF_LOCAL_NAME)
273-
for entry in self._async_current_entries()
274-
if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
275-
}
276-
for discovery in async_discovered_service_info(self.hass):
277-
if (
278-
discovery.address in current_addresses
279-
or discovery.name in current_unique_names
280-
or discovery.address in self._discovered_devices
281-
or YALE_MFR_ID not in discovery.manufacturer_data
282-
):
283-
continue
284-
self._discovered_devices[discovery.address] = discovery
285320

286321
if not self._discovered_devices:
287322
return self.async_abort(reason="no_devices_found")
@@ -290,14 +325,12 @@ async def async_step_user(
290325
{
291326
vol.Required(CONF_ADDRESS): vol.In(
292327
{
293-
service_info.address: (
294-
f"{service_info.name} ({service_info.address})"
328+
service_info.address: self._async_get_name_from_address(
329+
service_info.address
295330
)
296331
for service_info in self._discovered_devices.values()
297332
}
298-
),
299-
vol.Required(CONF_KEY): str,
300-
vol.Required(CONF_SLOT): int,
333+
)
301334
}
302335
)
303336
return self.async_show_form(
@@ -306,6 +339,18 @@ async def async_step_user(
306339
errors=errors,
307340
)
308341

342+
@callback
343+
def _async_get_name_from_address(self, address: str) -> str:
344+
"""Get the name of a device from its address."""
345+
if validated_config := async_get_validated_config(self.hass, address):
346+
return f"{validated_config.name} ({address})"
347+
if address in self._discovered_devices:
348+
service_info = self._discovered_devices[address]
349+
return f"{service_info.name} ({service_info.address})"
350+
assert self._discovery_info is not None
351+
assert self._discovery_info.address == address
352+
return f"{self._discovery_info.name} ({address})"
353+
309354
@staticmethod
310355
@callback
311356
def async_get_options_flow(

homeassistant/components/yalexs_ble/strings.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,23 @@
33
"flow_title": "{name}",
44
"step": {
55
"user": {
6-
"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.",
6+
"description": "Select the device you want to set up over Bluetooth.",
7+
"data": {
8+
"address": "Bluetooth address"
9+
}
10+
},
11+
"key_slot": {
12+
"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.",
713
"data": {
8-
"address": "Bluetooth address",
914
"key": "Offline Key (32-byte hex string)",
1015
"slot": "Offline Key Slot (Integer between 0 and 255)"
1116
}
1217
},
1318
"reauth_validate": {
14-
"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.",
19+
"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.",
1520
"data": {
16-
"key": "[%key:component::yalexs_ble::config::step::user::data::key%]",
17-
"slot": "[%key:component::yalexs_ble::config::step::user::data::slot%]"
21+
"key": "[%key:component::yalexs_ble::config::step::key_slot::data::key%]",
22+
"slot": "[%key:component::yalexs_ble::config::step::key_slot::data::slot%]"
1823
}
1924
},
2025
"integration_discovery_confirm": {

0 commit comments

Comments
 (0)