mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add support for dormakaba dKey locks (#87501)
* Add support for dormakaba dKey locks * Pylint * Address review comments * Add test for already configured entry * Add user flow * Address review comments * Simplify config flow * Add tests * Sort manifest * Remove useless _abort_if_unique_id_configured * Remove config entry update listener * Simplify user flow * Remove startup event * Revert "Simplify user flow" This reverts commit 0ef9d1c6bb452b3a06856bc6bf5e81303a33c6b9.
This commit is contained in:
parent
7aa1359c4a
commit
4db40810dd
@ -212,6 +212,8 @@ omit =
|
||||
homeassistant/components/doorbird/camera.py
|
||||
homeassistant/components/doorbird/entity.py
|
||||
homeassistant/components/doorbird/util.py
|
||||
homeassistant/components/dormakaba_dkey/__init__.py
|
||||
homeassistant/components/dormakaba_dkey/lock.py
|
||||
homeassistant/components/dovado/*
|
||||
homeassistant/components/downloader/*
|
||||
homeassistant/components/dsmr_reader/__init__.py
|
||||
|
@ -275,6 +275,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dnsip/ @gjohansson-ST
|
||||
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
|
||||
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
|
||||
/homeassistant/components/dormakaba_dkey/ @emontnemery
|
||||
/tests/components/dormakaba_dkey/ @emontnemery
|
||||
/homeassistant/components/dsmr/ @Robbie1221 @frenck
|
||||
/tests/components/dsmr/ @Robbie1221 @frenck
|
||||
/homeassistant/components/dsmr_reader/ @depl0y @glodenox
|
||||
|
97
homeassistant/components/dormakaba_dkey/__init__.py
Normal file
97
homeassistant/components/dormakaba_dkey/__init__.py
Normal file
@ -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
|
157
homeassistant/components/dormakaba_dkey/config_flow.py
Normal file
157
homeassistant/components/dormakaba_dkey/config_flow.py
Normal file
@ -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(
|
||||
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
|
||||
)
|
7
homeassistant/components/dormakaba_dkey/const.py
Normal file
7
homeassistant/components/dormakaba_dkey/const.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Constants for the Dormakaba dKey integration."""
|
||||
|
||||
DOMAIN = "dormakaba_dkey"
|
||||
|
||||
UPDATE_SECONDS = 120
|
||||
|
||||
CONF_ASSOCIATION_DATA = "association_data"
|
84
homeassistant/components/dormakaba_dkey/lock.py
Normal file
84
homeassistant/components/dormakaba_dkey/lock.py
Normal file
@ -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()
|
15
homeassistant/components/dormakaba_dkey/manifest.json
Normal file
15
homeassistant/components/dormakaba_dkey/manifest.json
Normal file
@ -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"]
|
||||
}
|
16
homeassistant/components/dormakaba_dkey/models.py
Normal file
16
homeassistant/components/dormakaba_dkey/models.py
Normal file
@ -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]
|
32
homeassistant/components/dormakaba_dkey/strings.json
Normal file
32
homeassistant/components/dormakaba_dkey/strings.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
"data": {
|
||||
"address": "[%key:component::bluetooth::config::step::user::data::address%]"
|
||||
}
|
||||
},
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"associate": {
|
||||
"description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.",
|
||||
"data": {
|
||||
"activation_code": "Activation code"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.",
|
||||
"wrong_code": "Wrong activation code. Note that an activation code can only be used once."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
@ -42,6 +42,14 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
|
||||
"domain": "bthome",
|
||||
"service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb",
|
||||
},
|
||||
{
|
||||
"domain": "dormakaba_dkey",
|
||||
"service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897",
|
||||
},
|
||||
{
|
||||
"domain": "dormakaba_dkey",
|
||||
"service_uuid": "e7a60001-6639-429f-94fd-86de8ea26897",
|
||||
},
|
||||
{
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9140",
|
||||
|
@ -97,6 +97,7 @@ FLOWS = {
|
||||
"dlna_dms",
|
||||
"dnsip",
|
||||
"doorbird",
|
||||
"dormakaba_dkey",
|
||||
"dsmr",
|
||||
"dsmr_reader",
|
||||
"dunehd",
|
||||
|
@ -1138,6 +1138,12 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
},
|
||||
"dormakaba_dkey": {
|
||||
"name": "Dormakaba dKey",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"dovado": {
|
||||
"name": "Dovado",
|
||||
"integration_type": "hub",
|
||||
|
@ -1429,6 +1429,9 @@ py-canary==0.5.3
|
||||
# homeassistant.components.cpuspeed
|
||||
py-cpuinfo==8.0.0
|
||||
|
||||
# homeassistant.components.dormakaba_dkey
|
||||
py-dormakaba-dkey==1.0.1
|
||||
|
||||
# homeassistant.components.melissa
|
||||
py-melissa-climate==2.1.4
|
||||
|
||||
|
@ -1044,6 +1044,9 @@ py-canary==0.5.3
|
||||
# homeassistant.components.cpuspeed
|
||||
py-cpuinfo==8.0.0
|
||||
|
||||
# homeassistant.components.dormakaba_dkey
|
||||
py-dormakaba-dkey==1.0.1
|
||||
|
||||
# homeassistant.components.melissa
|
||||
py-melissa-climate==2.1.4
|
||||
|
||||
|
40
tests/components/dormakaba_dkey/__init__.py
Normal file
40
tests/components/dormakaba_dkey/__init__.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Tests for the Dormakaba dKey integration."""
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from tests.components.bluetooth import generate_advertisement_data
|
||||
|
||||
DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak(
|
||||
name="00123456",
|
||||
address="AA:BB:CC:DD:EE:F0",
|
||||
rssi=-60,
|
||||
manufacturer_data={},
|
||||
service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(address="AA:BB:CC:DD:EE:F0", name="00123456"),
|
||||
advertisement=generate_advertisement_data(
|
||||
service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"]
|
||||
),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
||||
|
||||
|
||||
NOT_DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak(
|
||||
name="Not",
|
||||
address="AA:BB:CC:DD:EE:F2",
|
||||
rssi=-60,
|
||||
manufacturer_data={
|
||||
33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9",
|
||||
21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0",
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(address="AA:BB:CC:DD:EE:F2", name="Aug"),
|
||||
advertisement=generate_advertisement_data(),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
8
tests/components/dormakaba_dkey/conftest.py
Normal file
8
tests/components/dormakaba_dkey/conftest.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Dormakaba dKey test fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_bluetooth(enable_bluetooth):
|
||||
"""Auto mock bluetooth."""
|
296
tests/components/dormakaba_dkey/test_config_flow.py
Normal file
296
tests/components/dormakaba_dkey/test_config_flow.py
Normal file
@ -0,0 +1,296 @@
|
||||
"""Test the Dormakaba dKey config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from py_dormakaba_dkey import errors as dkey_errors
|
||||
from py_dormakaba_dkey.models import AssociationData
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.dormakaba_dkey.const import DOMAIN
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult, FlowResultType
|
||||
|
||||
from . import DKEY_DISCOVERY_INFO, NOT_DKEY_DISCOVERY_INFO
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_step_success(hass: HomeAssistant) -> None:
|
||||
"""Test user step success path."""
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info",
|
||||
return_value=[NOT_DKEY_DISCOVERY_INFO, DKEY_DISCOVERY_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_ADDRESS: DKEY_DISCOVERY_INFO.address,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] is None
|
||||
|
||||
await _test_common_success(hass, result)
|
||||
|
||||
|
||||
async def test_user_step_no_devices_found(hass: HomeAssistant) -> None:
|
||||
"""Test user step with no devices found."""
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info",
|
||||
return_value=[NOT_DKEY_DISCOVERY_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None:
|
||||
"""Test user step with only existing devices found."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_ADDRESS: DKEY_DISCOVERY_INFO.address,
|
||||
},
|
||||
unique_id=DKEY_DISCOVERY_INFO.address,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info",
|
||||
return_value=[DKEY_DISCOVERY_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_user_step_device_added_between_steps_1(hass: HomeAssistant) -> None:
|
||||
"""Test the device gets added via another flow between steps."""
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info",
|
||||
return_value=[DKEY_DISCOVERY_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DKEY_DISCOVERY_INFO.address,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"address": DKEY_DISCOVERY_INFO.address},
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_async_step_user_takes_precedence_over_discovery(hass):
|
||||
"""Test manual setup takes precedence over discovery."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info",
|
||||
return_value=[DKEY_DISCOVERY_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_ADDRESS: DKEY_DISCOVERY_INFO.address,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] is None
|
||||
|
||||
await _test_common_success(hass, result)
|
||||
|
||||
# Verify the discovery flow was aborted
|
||||
assert not hass.config_entries.flow.async_progress(DOMAIN)
|
||||
|
||||
|
||||
async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
|
||||
"""Test bluetooth step success path."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] is None
|
||||
|
||||
await _test_common_success(hass, result)
|
||||
|
||||
|
||||
async def _test_common_success(hass: HomeAssistant, result: FlowResult) -> None:
|
||||
"""Test bluetooth and user flow success paths."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate",
|
||||
return_value=AssociationData(b"1234", b"AABBCCDD"),
|
||||
) as mock_associate, patch(
|
||||
"homeassistant.components.dormakaba_dkey.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"activation_code": "1234-1234"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == DKEY_DISCOVERY_INFO.name
|
||||
assert result["data"] == {
|
||||
CONF_ADDRESS: DKEY_DISCOVERY_INFO.address,
|
||||
"association_data": {"key_holder_id": "31323334", "secret": "4141424243434444"},
|
||||
}
|
||||
assert result["options"] == {}
|
||||
assert result["result"].unique_id == DKEY_DISCOVERY_INFO.address
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
mock_associate.assert_awaited_once_with("1234-1234")
|
||||
|
||||
|
||||
async def test_bluetooth_step_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test bluetooth step success path."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, unique_id=DKEY_DISCOVERY_INFO.address)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_bluetooth_step_already_in_progress(hass):
|
||||
"""Test we can't start a flow for the same device twice."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc, error",
|
||||
(
|
||||
(BleakError, "cannot_connect"),
|
||||
(Exception, "unknown"),
|
||||
),
|
||||
)
|
||||
async def test_bluetooth_step_cannot_connect(hass: HomeAssistant, exc, error) -> None:
|
||||
"""Test bluetooth step and we cannot connect."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] is None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate",
|
||||
side_effect=exc,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"activation_code": "1234-1234"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc, error",
|
||||
(
|
||||
(dkey_errors.InvalidActivationCode, "invalid_code"),
|
||||
(dkey_errors.WrongActivationCode, "wrong_code"),
|
||||
),
|
||||
)
|
||||
async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) -> None:
|
||||
"""Test bluetooth step and we cannot associate."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=DKEY_DISCOVERY_INFO,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] is None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate",
|
||||
side_effect=exc,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"activation_code": "1234-1234"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "associate"
|
||||
assert result["errors"] == {"base": error}
|
Loading…
x
Reference in New Issue
Block a user