-
-
Notifications
You must be signed in to change notification settings - Fork 37.6k
Add support for dormakaba dKey locks #87501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
399ef3b
Add support for dormakaba dKey locks
emontnemery 98e6582
Pylint
emontnemery b421c62
Address review comments
emontnemery 6d22745
Add test for already configured entry
emontnemery bc18629
Add user flow
emontnemery 027b2d2
Address review comments
emontnemery 8cea551
Simplify config flow
emontnemery e130ab9
Add tests
emontnemery e4a4024
Sort manifest
emontnemery 0ca2cce
Remove useless _abort_if_unique_id_configured
emontnemery 9a088fa
Remove config entry update listener
emontnemery 0ef9d1c
Simplify user flow
emontnemery 65781a4
Remove startup event
emontnemery cb4fb53
Revert "Simplify user flow"
emontnemery File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| """The Dormakaba dKey integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| from py_dormakaba_dkey import DKEYLock | ||
| from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS | ||
| from py_dormakaba_dkey.models import AssociationData | ||
|
|
||
| from homeassistant.components import bluetooth | ||
| from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform | ||
| from homeassistant.core import Event, HomeAssistant, callback | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS | ||
| from .models import DormakabaDkeyData | ||
|
|
||
| PLATFORMS: list[Platform] = [Platform.LOCK] | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up Dormakaba dKey from a config entry.""" | ||
| address: str = entry.data[CONF_ADDRESS] | ||
| ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) | ||
| if not ble_device: | ||
| raise ConfigEntryNotReady(f"Could not find dKey device with address {address}") | ||
|
|
||
| lock = DKEYLock(ble_device) | ||
| lock.set_association_data( | ||
| AssociationData.from_json(entry.data[CONF_ASSOCIATION_DATA]) | ||
| ) | ||
|
|
||
| @callback | ||
| def _async_update_ble( | ||
| service_info: bluetooth.BluetoothServiceInfoBleak, | ||
| change: bluetooth.BluetoothChange, | ||
| ) -> None: | ||
| """Update from a ble callback.""" | ||
| lock.set_ble_device_and_advertisement_data( | ||
| service_info.device, service_info.advertisement | ||
| ) | ||
|
|
||
| entry.async_on_unload( | ||
| bluetooth.async_register_callback( | ||
| hass, | ||
| _async_update_ble, | ||
| BluetoothCallbackMatcher({ADDRESS: address}), | ||
| bluetooth.BluetoothScanningMode.PASSIVE, | ||
| ) | ||
| ) | ||
|
|
||
| async def _async_update() -> None: | ||
| """Update the device state.""" | ||
| try: | ||
| await lock.update() | ||
| await lock.disconnect() | ||
| except DKEY_EXCEPTIONS as ex: | ||
| raise UpdateFailed(str(ex)) from ex | ||
|
|
||
| coordinator = DataUpdateCoordinator( | ||
| hass, | ||
| _LOGGER, | ||
| name=lock.name, | ||
| update_method=_async_update, | ||
| update_interval=timedelta(seconds=UPDATE_SECONDS), | ||
| ) | ||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( | ||
| lock, coordinator | ||
| ) | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
|
||
| async def _async_stop(event: Event) -> None: | ||
| """Close the connection.""" | ||
| await lock.disconnect() | ||
|
|
||
| entry.async_on_unload( | ||
| hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) | ||
| ) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): | ||
| data: DormakabaDkeyData = hass.data[DOMAIN].pop(entry.entry_id) | ||
| await data.lock.disconnect() | ||
|
|
||
| return unload_ok | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| """Config flow for Dormakaba dKey integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from bleak import BleakError | ||
| from py_dormakaba_dkey import DKEYLock, device_filter, errors as dkey_errors | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.components.bluetooth import ( | ||
| BluetoothServiceInfoBleak, | ||
| async_discovered_service_info, | ||
| ) | ||
| from homeassistant.const import CONF_ADDRESS | ||
| from homeassistant.data_entry_flow import FlowResult | ||
|
|
||
| from .const import CONF_ASSOCIATION_DATA, DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| STEP_ASSOCIATE_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required("activation_code"): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Dormakaba dKey.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| def __init__(self) -> None: | ||
| """Initialize the config flow.""" | ||
| self._lock: DKEYLock | None = None | ||
| # Populated by user step | ||
| self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} | ||
| # Populated by bluetooth and user steps | ||
| self._discovery_info: BluetoothServiceInfoBleak | None = None | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle the user step to pick discovered device.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| address = user_input[CONF_ADDRESS] | ||
| await self.async_set_unique_id(address, raise_on_progress=False) | ||
| # Guard against the user selecting a device which has been configured by | ||
| # another flow. | ||
| self._abort_if_unique_id_configured() | ||
| self._discovery_info = self._discovered_devices[address] | ||
| return await self.async_step_associate() | ||
|
|
||
| current_addresses = self._async_current_ids() | ||
| for discovery in async_discovered_service_info(self.hass): | ||
| if ( | ||
| discovery.address in current_addresses | ||
| or discovery.address in self._discovered_devices | ||
| or not device_filter(discovery.advertisement) | ||
| ): | ||
| continue | ||
| self._discovered_devices[discovery.address] = discovery | ||
|
|
||
| if not self._discovered_devices: | ||
| return self.async_abort(reason="no_devices_found") | ||
|
|
||
| data_schema = vol.Schema( | ||
| { | ||
| vol.Required(CONF_ADDRESS): vol.In( | ||
| { | ||
| service_info.address: ( | ||
| f"{service_info.name} ({service_info.address})" | ||
| ) | ||
| for service_info in self._discovered_devices.values() | ||
| } | ||
| ), | ||
| } | ||
| ) | ||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=data_schema, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_bluetooth( | ||
|
MartinHjelmare marked this conversation as resolved.
|
||
| self, discovery_info: BluetoothServiceInfoBleak | ||
| ) -> FlowResult: | ||
| """Handle the Bluetooth discovery step.""" | ||
| await self.async_set_unique_id(discovery_info.address) | ||
| self._abort_if_unique_id_configured() | ||
| self._discovery_info = discovery_info | ||
| name = self._discovery_info.name or self._discovery_info.address | ||
| self.context["title_placeholders"] = {"name": name} | ||
| return await self.async_step_bluetooth_confirm() | ||
|
|
||
| async def async_step_bluetooth_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle bluetooth confirm step.""" | ||
| # mypy is not aware that we can't get here without having these set already | ||
| assert self._discovery_info is not None | ||
|
|
||
| if user_input is None: | ||
| name = self._discovery_info.name or self._discovery_info.address | ||
| return self.async_show_form( | ||
| step_id="bluetooth_confirm", | ||
| description_placeholders={"name": name}, | ||
| ) | ||
|
|
||
| return await self.async_step_associate() | ||
|
|
||
| async def async_step_associate( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle associate step.""" | ||
| # mypy is not aware that we can't get here without having these set already | ||
| assert self._discovery_info is not None | ||
|
|
||
| if user_input is None: | ||
| return self.async_show_form( | ||
| step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA | ||
| ) | ||
|
|
||
| errors = {} | ||
| if not self._lock: | ||
| self._lock = DKEYLock(self._discovery_info.device) | ||
| lock = self._lock | ||
|
|
||
| try: | ||
| association_data = await lock.associate(user_input["activation_code"]) | ||
| except BleakError: | ||
| return self.async_abort(reason="cannot_connect") | ||
| except dkey_errors.InvalidActivationCode: | ||
| errors["base"] = "invalid_code" | ||
| except dkey_errors.WrongActivationCode: | ||
| errors["base"] = "wrong_code" | ||
| except Exception: # pylint: disable=broad-except | ||
| _LOGGER.exception("Unexpected exception") | ||
| return self.async_abort(reason="unknown") | ||
| else: | ||
| return self.async_create_entry( | ||
| title=lock.device_info.device_name | ||
| or lock.device_info.device_id | ||
| or lock.name, | ||
| data={ | ||
| CONF_ADDRESS: self._discovery_info.device.address, | ||
| CONF_ASSOCIATION_DATA: association_data.to_json(), | ||
| }, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="associate", data_schema=STEP_ASSOCIATE_SCHEMA, errors=errors | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """Constants for the Dormakaba dKey integration.""" | ||
|
|
||
| DOMAIN = "dormakaba_dkey" | ||
|
|
||
| UPDATE_SECONDS = 120 | ||
|
|
||
| CONF_ASSOCIATION_DATA = "association_data" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Dormakaba dKey integration lock platform.""" | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
| from py_dormakaba_dkey import DKEYLock | ||
| from py_dormakaba_dkey.commands import Notifications, UnlockStatus | ||
|
|
||
| from homeassistant.components.lock import LockEntity | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.helpers import device_registry as dr | ||
| from homeassistant.helpers.entity import DeviceInfo | ||
| from homeassistant.helpers.entity_platform import AddEntitiesCallback | ||
| from homeassistant.helpers.update_coordinator import ( | ||
| CoordinatorEntity, | ||
| DataUpdateCoordinator, | ||
| ) | ||
|
|
||
| from .const import DOMAIN | ||
| from .models import DormakabaDkeyData | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, | ||
| entry: ConfigEntry, | ||
| async_add_entities: AddEntitiesCallback, | ||
| ) -> None: | ||
| """Set up the lock platform for Dormakaba dKey.""" | ||
| data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] | ||
| async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)]) | ||
|
|
||
|
|
||
| class DormakabaDkeyLock(CoordinatorEntity[DataUpdateCoordinator[None]], LockEntity): | ||
| """Representation of Dormakaba dKey lock.""" | ||
|
|
||
| _attr_has_entity_name = True | ||
|
|
||
| def __init__( | ||
| self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock | ||
| ) -> None: | ||
| """Initialize a Dormakaba dKey lock.""" | ||
| super().__init__(coordinator) | ||
| self._lock = lock | ||
| self._attr_unique_id = lock.address | ||
| self._attr_device_info = DeviceInfo( | ||
| name=lock.device_info.device_name or lock.device_info.device_id, | ||
| model="MTL 9291", | ||
| sw_version=lock.device_info.sw_version, | ||
| connections={(dr.CONNECTION_BLUETOOTH, lock.address)}, | ||
| ) | ||
| self._async_update_attrs() | ||
|
|
||
| @callback | ||
| def _async_update_attrs(self) -> None: | ||
| """Handle updating _attr values.""" | ||
| self._attr_is_locked = self._lock.state.unlock_status in ( | ||
| UnlockStatus.LOCKED, | ||
| UnlockStatus.SECURITY_LOCKED, | ||
| ) | ||
|
|
||
| async def async_lock(self, **kwargs: Any) -> None: | ||
| """Lock the lock.""" | ||
| await self._lock.lock() | ||
|
|
||
| async def async_unlock(self, **kwargs: Any) -> None: | ||
| """Unlock the lock.""" | ||
| await self._lock.unlock() | ||
|
|
||
| @callback | ||
| def _handle_coordinator_update(self) -> None: | ||
| """Handle data update.""" | ||
| self._async_update_attrs() | ||
| self.async_write_ha_state() | ||
|
|
||
| @callback | ||
| def _handle_state_update(self, update: Notifications) -> None: | ||
| """Handle data update.""" | ||
| self.coordinator.async_set_updated_data(None) | ||
|
|
||
| async def async_added_to_hass(self) -> None: | ||
| """Register callbacks.""" | ||
| self.async_on_remove(self._lock.register_callback(self._handle_state_update)) | ||
| return await super().async_added_to_hass() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "domain": "dormakaba_dkey", | ||
| "name": "Dormakaba dKey", | ||
| "bluetooth": [ | ||
| { "service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897" }, | ||
| { "service_uuid": "e7a60001-6639-429f-94fd-86de8ea26897" } | ||
| ], | ||
| "codeowners": ["@emontnemery"], | ||
| "config_flow": true, | ||
| "dependencies": ["bluetooth_adapters"], | ||
| "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", | ||
| "integration_type": "device", | ||
| "iot_class": "local_polling", | ||
| "requirements": ["py-dormakaba-dkey==1.0.1"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| """The Dormakaba dKey integration models.""" | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
|
|
||
| from py_dormakaba_dkey import DKEYLock | ||
|
|
||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||
|
|
||
|
|
||
| @dataclass | ||
| class DormakabaDkeyData: | ||
| """Data for the Dormakaba dKey integration.""" | ||
|
|
||
| lock: DKEYLock | ||
| coordinator: DataUpdateCoordinator[None] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Observation: The naming throws me off here, data I would expect be some data description, not a class with connection properties.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The class is a container of data which the integration stores in hass.data[], what would be a better name?
Maybe
DkeyConfigEntryData?