mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +00:00
Add tedee integration (#102846)
* init tedee * init tests * add config flow tests * liniting * test * undo * linting * pylint * add tests * more tests * more tests * update snapshot * more tests * typing * strict typing * cleanups * cleanups, fix tests * remove extra platforms * remove codeowner * improvements * catch tedeeclientexception * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * abort earlier * auto-select bridge * remove cloud token, optionsflow to remove size * remove options flow leftovers * improve coverage * defer coordinator setting to after first update * define coordinator * some improvements * remove diagnostics, webhook * remove reauth flow, freeze data classes * fix lock test * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/__init__.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * requested changes * requested changes * Update lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * requested changes * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/tedee/conftest.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/coordinator.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/tedee/test_config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * requested changes * requested changes * requested changes * revert load fixture * change tests * Update test_config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update strings.json Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update coordinator.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * remove warning * move stuff out of try * add docstring * tedee lowercase, time.time * back to some uppercase, time.time * awaitable --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
56a58f9285
commit
02b863e968
@ -344,6 +344,7 @@ homeassistant.components.tailwind.*
|
|||||||
homeassistant.components.tami4.*
|
homeassistant.components.tami4.*
|
||||||
homeassistant.components.tautulli.*
|
homeassistant.components.tautulli.*
|
||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
|
homeassistant.components.tedee.*
|
||||||
homeassistant.components.text.*
|
homeassistant.components.text.*
|
||||||
homeassistant.components.threshold.*
|
homeassistant.components.threshold.*
|
||||||
homeassistant.components.tibber.*
|
homeassistant.components.tibber.*
|
||||||
|
@ -1316,6 +1316,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tasmota/ @emontnemery
|
/tests/components/tasmota/ @emontnemery
|
||||||
/homeassistant/components/tautulli/ @ludeeus @tkdrob
|
/homeassistant/components/tautulli/ @ludeeus @tkdrob
|
||||||
/tests/components/tautulli/ @ludeeus @tkdrob
|
/tests/components/tautulli/ @ludeeus @tkdrob
|
||||||
|
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||||
|
/tests/components/tedee/ @patrickhilker @zweckj
|
||||||
/homeassistant/components/tellduslive/ @fredrike
|
/homeassistant/components/tellduslive/ @fredrike
|
||||||
/tests/components/tellduslive/ @fredrike
|
/tests/components/tellduslive/ @fredrike
|
||||||
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||||
|
40
homeassistant/components/tedee/__init__.py
Normal file
40
homeassistant/components/tedee/__init__.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Init the tedee component."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TedeeApiCoordinator
|
||||||
|
|
||||||
|
PLATFORMS = [
|
||||||
|
Platform.LOCK,
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Integration setup."""
|
||||||
|
|
||||||
|
coordinator = TedeeApiCoordinator(hass)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
53
homeassistant/components/tedee/config_flow.py
Normal file
53
homeassistant/components/tedee/config_flow.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""Config flow for Tedee integration."""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pytedee_async import (
|
||||||
|
TedeeAuthException,
|
||||||
|
TedeeClient,
|
||||||
|
TedeeClientException,
|
||||||
|
TedeeLocalAuthException,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME
|
||||||
|
|
||||||
|
|
||||||
|
class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Tedee."""
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
host = user_input[CONF_HOST]
|
||||||
|
local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN]
|
||||||
|
tedee_client = TedeeClient(local_token=local_access_token, local_ip=host)
|
||||||
|
try:
|
||||||
|
local_bridge = await tedee_client.get_local_bridge()
|
||||||
|
except (TedeeAuthException, TedeeLocalAuthException):
|
||||||
|
errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key"
|
||||||
|
except TedeeClientException:
|
||||||
|
errors[CONF_HOST] = "invalid_host"
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(local_bridge.serial)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(title=NAME, data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Required(CONF_LOCAL_ACCESS_TOKEN): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
9
homeassistant/components/tedee/const.py
Normal file
9
homeassistant/components/tedee/const.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"""Constants for the Tedee integration."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
DOMAIN = "tedee"
|
||||||
|
NAME = "Tedee"
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=10)
|
||||||
|
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN = "local_access_token"
|
85
homeassistant/components/tedee/coordinator.py
Normal file
85
homeassistant/components/tedee/coordinator.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Coordinator for Tedee locks."""
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pytedee_async import (
|
||||||
|
TedeeClient,
|
||||||
|
TedeeClientException,
|
||||||
|
TedeeDataUpdateException,
|
||||||
|
TedeeLocalAuthException,
|
||||||
|
TedeeLock,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=20)
|
||||||
|
GET_LOCKS_INTERVAL_SECONDS = 3600
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
|
||||||
|
"""Class to handle fetching data from the tedee API centrally."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tedee_client = TedeeClient(
|
||||||
|
local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN],
|
||||||
|
local_ip=self.config_entry.data[CONF_HOST],
|
||||||
|
)
|
||||||
|
|
||||||
|
self._next_get_locks = time.time()
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[int, TedeeLock]:
|
||||||
|
"""Fetch data from API endpoint."""
|
||||||
|
|
||||||
|
_LOGGER.debug("Update coordinator: Getting locks from API")
|
||||||
|
# once every hours get all lock details, otherwise use the sync endpoint
|
||||||
|
if self._next_get_locks <= time.time():
|
||||||
|
_LOGGER.debug("Updating through /my/lock endpoint")
|
||||||
|
await self._async_update_locks(self.tedee_client.get_locks)
|
||||||
|
self._next_get_locks = time.time() + GET_LOCKS_INTERVAL_SECONDS
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Updating through /sync endpoint")
|
||||||
|
await self._async_update_locks(self.tedee_client.sync)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"available_locks: %s",
|
||||||
|
", ".join(map(str, self.tedee_client.locks_dict.keys())),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.tedee_client.locks_dict
|
||||||
|
|
||||||
|
async def _async_update_locks(
|
||||||
|
self, update_fn: Callable[[], Awaitable[None]]
|
||||||
|
) -> None:
|
||||||
|
"""Update locks based on update function."""
|
||||||
|
try:
|
||||||
|
await update_fn()
|
||||||
|
except TedeeLocalAuthException as ex:
|
||||||
|
raise ConfigEntryError(
|
||||||
|
"Authentication failed. Local access token is invalid"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
except TedeeDataUpdateException as ex:
|
||||||
|
_LOGGER.debug("Error while updating data: %s", str(ex))
|
||||||
|
raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex
|
||||||
|
except (TedeeClientException, TimeoutError) as ex:
|
||||||
|
raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex
|
40
homeassistant/components/tedee/entity.py
Normal file
40
homeassistant/components/tedee/entity.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Bases for Tedee entities."""
|
||||||
|
|
||||||
|
from pytedee_async.lock import TedeeLock
|
||||||
|
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TedeeApiCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]):
|
||||||
|
"""Base class for Tedee entities."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lock: TedeeLock,
|
||||||
|
coordinator: TedeeApiCoordinator,
|
||||||
|
key: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize Tedee entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._lock = lock
|
||||||
|
self._attr_unique_id = f"{lock.lock_id}-{key}"
|
||||||
|
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, str(lock.lock_id))},
|
||||||
|
name=lock.lock_name,
|
||||||
|
manufacturer="tedee",
|
||||||
|
model=lock.lock_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Handle updated data from the coordinator."""
|
||||||
|
self._lock = self.coordinator.data[self._lock.lock_id]
|
||||||
|
super()._handle_coordinator_update()
|
119
homeassistant/components/tedee/lock.py
Normal file
119
homeassistant/components/tedee/lock.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Tedee lock entities."""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState
|
||||||
|
|
||||||
|
from homeassistant.components.lock import LockEntity, LockEntityFeature
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TedeeApiCoordinator
|
||||||
|
from .entity import TedeeEntity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Tedee lock entity."""
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
entities: list[TedeeLockEntity] = []
|
||||||
|
for lock in coordinator.data.values():
|
||||||
|
if lock.is_enabled_pullspring:
|
||||||
|
entities.append(TedeeLockWithLatchEntity(lock, coordinator))
|
||||||
|
else:
|
||||||
|
entities.append(TedeeLockEntity(lock, coordinator))
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class TedeeLockEntity(TedeeEntity, LockEntity):
|
||||||
|
"""A tedee lock that doesn't have pullspring enabled."""
|
||||||
|
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lock: TedeeLock,
|
||||||
|
coordinator: TedeeApiCoordinator,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the lock."""
|
||||||
|
super().__init__(lock, coordinator, "lock")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locked(self) -> bool:
|
||||||
|
"""Return true if lock is locked."""
|
||||||
|
return self._lock.state == TedeeLockState.LOCKED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_unlocking(self) -> bool:
|
||||||
|
"""Return true if lock is unlocking."""
|
||||||
|
return self._lock.state == TedeeLockState.UNLOCKING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locking(self) -> bool:
|
||||||
|
"""Return true if lock is locking."""
|
||||||
|
return self._lock.state == TedeeLockState.LOCKING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_jammed(self) -> bool:
|
||||||
|
"""Return true if lock is jammed."""
|
||||||
|
return self._lock.is_state_jammed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return super().available and self._lock.is_connected
|
||||||
|
|
||||||
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
|
"""Unlock the door."""
|
||||||
|
try:
|
||||||
|
self._lock.state = TedeeLockState.UNLOCKING
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
await self.coordinator.tedee_client.unlock(self._lock.lock_id)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
except (TedeeClientException, Exception) as ex:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Failed to unlock the door. Lock %s" % self._lock.lock_id
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
|
"""Lock the door."""
|
||||||
|
try:
|
||||||
|
self._lock.state = TedeeLockState.LOCKING
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
await self.coordinator.tedee_client.lock(self._lock.lock_id)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
except (TedeeClientException, Exception) as ex:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Failed to lock the door. Lock %s" % self._lock.lock_id
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
|
||||||
|
class TedeeLockWithLatchEntity(TedeeLockEntity):
|
||||||
|
"""A tedee lock but has pullspring enabled, so it additional features."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> LockEntityFeature:
|
||||||
|
"""Flag supported features."""
|
||||||
|
return LockEntityFeature.OPEN
|
||||||
|
|
||||||
|
async def async_open(self, **kwargs: Any) -> None:
|
||||||
|
"""Open the door with pullspring."""
|
||||||
|
try:
|
||||||
|
self._lock.state = TedeeLockState.UNLOCKING
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
await self.coordinator.tedee_client.open(self._lock.lock_id)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
except (TedeeClientException, Exception) as ex:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Failed to unlatch the door. Lock %s" % self._lock.lock_id
|
||||||
|
) from ex
|
10
homeassistant/components/tedee/manifest.json
Normal file
10
homeassistant/components/tedee/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "tedee",
|
||||||
|
"name": "Tedee",
|
||||||
|
"codeowners": ["@patrickhilker", "@zweckj"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["http"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/tedee",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"requirements": ["pytedee-async==0.2.1"]
|
||||||
|
}
|
25
homeassistant/components/tedee/strings.json
Normal file
25
homeassistant/components/tedee/strings.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Setup your tedee locks",
|
||||||
|
"data": {
|
||||||
|
"local_access_token": "Local access token",
|
||||||
|
"host": "[%key:common::config_flow::data::host%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The IP address of the bridge you want to connect to.",
|
||||||
|
"local_access_token": "You can find it in the tedee app under \"Bridge Settings\" -> \"Local API\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||||
|
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -500,6 +500,7 @@ FLOWS = {
|
|||||||
"tankerkoenig",
|
"tankerkoenig",
|
||||||
"tasmota",
|
"tasmota",
|
||||||
"tautulli",
|
"tautulli",
|
||||||
|
"tedee",
|
||||||
"tellduslive",
|
"tellduslive",
|
||||||
"tesla_wall_connector",
|
"tesla_wall_connector",
|
||||||
"tessie",
|
"tessie",
|
||||||
|
@ -5804,6 +5804,12 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"tedee": {
|
||||||
|
"name": "Tedee",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"name": "Telegram",
|
"name": "Telegram",
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
10
mypy.ini
10
mypy.ini
@ -3202,6 +3202,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.tedee.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.text.*]
|
[mypy-homeassistant.components.text.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -2134,6 +2134,9 @@ pytankerkoenig==0.0.6
|
|||||||
# homeassistant.components.tautulli
|
# homeassistant.components.tautulli
|
||||||
pytautulli==23.1.1
|
pytautulli==23.1.1
|
||||||
|
|
||||||
|
# homeassistant.components.tedee
|
||||||
|
pytedee-async==0.2.1
|
||||||
|
|
||||||
# homeassistant.components.tfiac
|
# homeassistant.components.tfiac
|
||||||
pytfiac==0.4
|
pytfiac==0.4
|
||||||
|
|
||||||
|
@ -1631,6 +1631,9 @@ pytankerkoenig==0.0.6
|
|||||||
# homeassistant.components.tautulli
|
# homeassistant.components.tautulli
|
||||||
pytautulli==23.1.1
|
pytautulli==23.1.1
|
||||||
|
|
||||||
|
# homeassistant.components.tedee
|
||||||
|
pytedee-async==0.2.1
|
||||||
|
|
||||||
# homeassistant.components.motionmount
|
# homeassistant.components.motionmount
|
||||||
python-MotionMount==0.3.1
|
python-MotionMount==0.3.1
|
||||||
|
|
||||||
|
1
tests/components/tedee/__init__.py
Normal file
1
tests/components/tedee/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Add tests for Tedee components."""
|
81
tests/components/tedee/conftest.py
Normal file
81
tests/components/tedee/conftest.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Fixtures for Tedee integration tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from pytedee_async.bridge import TedeeBridge
|
||||||
|
from pytedee_async.lock import TedeeLock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
"""Return the default mocked config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
title="My Tedee",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN: "api_token",
|
||||||
|
CONF_HOST: "192.168.1.42",
|
||||||
|
},
|
||||||
|
unique_id="0000-0000",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock setting up a config entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.tedee.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup:
|
||||||
|
yield mock_setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tedee(request) -> Generator[MagicMock, None, None]:
|
||||||
|
"""Return a mocked Tedee client."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.tedee.coordinator.TedeeClient", autospec=True
|
||||||
|
) as tedee_mock, patch(
|
||||||
|
"homeassistant.components.tedee.config_flow.TedeeClient",
|
||||||
|
new=tedee_mock,
|
||||||
|
):
|
||||||
|
tedee = tedee_mock.return_value
|
||||||
|
|
||||||
|
tedee.get_locks.return_value = None
|
||||||
|
tedee.sync.return_value = None
|
||||||
|
tedee.get_bridges.return_value = [
|
||||||
|
TedeeBridge(1234, "0000-0000", "Bridge-AB1C"),
|
||||||
|
TedeeBridge(5678, "9999-9999", "Bridge-CD2E"),
|
||||||
|
]
|
||||||
|
tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C")
|
||||||
|
|
||||||
|
tedee.parse_webhook_message.return_value = None
|
||||||
|
|
||||||
|
locks_json = json.loads(load_fixture("locks.json", DOMAIN))
|
||||||
|
|
||||||
|
lock_list = [TedeeLock(**lock) for lock in locks_json]
|
||||||
|
tedee.locks_dict = {lock.lock_id: lock for lock in lock_list}
|
||||||
|
|
||||||
|
yield tedee
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def init_integration(
|
||||||
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Set up the Tedee integration for testing."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return mock_config_entry
|
26
tests/components/tedee/fixtures/locks.json
Normal file
26
tests/components/tedee/fixtures/locks.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"lock_name": "Lock-1A2B",
|
||||||
|
"lock_id": 12345,
|
||||||
|
"lock_type": 2,
|
||||||
|
"state": 2,
|
||||||
|
"battery_level": 70,
|
||||||
|
"is_connected": true,
|
||||||
|
"is_charging": false,
|
||||||
|
"state_change_result": 0,
|
||||||
|
"is_enabled_pullspring": 1,
|
||||||
|
"duration_pullspring": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lock_name": "Lock-2C3D",
|
||||||
|
"lock_id": 98765,
|
||||||
|
"lock_type": 4,
|
||||||
|
"state": 2,
|
||||||
|
"battery_level": 70,
|
||||||
|
"is_connected": true,
|
||||||
|
"is_charging": false,
|
||||||
|
"state_change_result": 0,
|
||||||
|
"is_enabled_pullspring": 0,
|
||||||
|
"duration_pullspring": 0
|
||||||
|
}
|
||||||
|
]
|
145
tests/components/tedee/snapshots/test_lock.ambr
Normal file
145
tests/components/tedee/snapshots/test_lock.ambr
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_lock
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'Lock-1A2B',
|
||||||
|
'supported_features': <LockEntityFeature: 1>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'lock.lock_1a2b',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unlocked',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_lock.1
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'lock',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'lock.lock_1a2b',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': None,
|
||||||
|
'platform': 'tedee',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': <LockEntityFeature: 1>,
|
||||||
|
'translation_key': None,
|
||||||
|
'unique_id': '12345-lock',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_lock.2
|
||||||
|
DeviceRegistryEntrySnapshot({
|
||||||
|
'area_id': None,
|
||||||
|
'config_entries': <ANY>,
|
||||||
|
'configuration_url': None,
|
||||||
|
'connections': set({
|
||||||
|
}),
|
||||||
|
'disabled_by': None,
|
||||||
|
'entry_type': None,
|
||||||
|
'hw_version': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'identifiers': set({
|
||||||
|
tuple(
|
||||||
|
'tedee',
|
||||||
|
'12345',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'is_new': False,
|
||||||
|
'manufacturer': 'tedee',
|
||||||
|
'model': 'Tedee PRO',
|
||||||
|
'name': 'Lock-1A2B',
|
||||||
|
'name_by_user': None,
|
||||||
|
'serial_number': None,
|
||||||
|
'suggested_area': None,
|
||||||
|
'sw_version': None,
|
||||||
|
'via_device_id': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_lock_without_pullspring
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'Lock-2C3D',
|
||||||
|
'supported_features': <LockEntityFeature: 0>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'lock.lock_2c3d',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unlocked',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_lock_without_pullspring.1
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'lock',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'lock.lock_2c3d',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': None,
|
||||||
|
'platform': 'tedee',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': 0,
|
||||||
|
'translation_key': None,
|
||||||
|
'unique_id': '98765-lock',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_lock_without_pullspring.2
|
||||||
|
DeviceRegistryEntrySnapshot({
|
||||||
|
'area_id': None,
|
||||||
|
'config_entries': <ANY>,
|
||||||
|
'configuration_url': None,
|
||||||
|
'connections': set({
|
||||||
|
}),
|
||||||
|
'disabled_by': None,
|
||||||
|
'entry_type': None,
|
||||||
|
'hw_version': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'identifiers': set({
|
||||||
|
tuple(
|
||||||
|
'tedee',
|
||||||
|
'98765',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'is_new': False,
|
||||||
|
'manufacturer': 'tedee',
|
||||||
|
'model': 'Tedee GO',
|
||||||
|
'name': 'Lock-2C3D',
|
||||||
|
'name_by_user': None,
|
||||||
|
'serial_number': None,
|
||||||
|
'suggested_area': None,
|
||||||
|
'sw_version': None,
|
||||||
|
'via_device_id': None,
|
||||||
|
})
|
||||||
|
# ---
|
102
tests/components/tedee/test_config_flow.py
Normal file
102
tests/components/tedee/test_config_flow.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""Test the Tedee config flow."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pytedee_async import TedeeClientException, TedeeLocalAuthException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
FLOW_UNIQUE_ID = "112233445566778899"
|
||||||
|
LOCAL_ACCESS_TOKEN = "api_token"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None:
|
||||||
|
"""Test config flow with one bridge."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "192.168.1.62",
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN: "token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2["data"] == {
|
||||||
|
CONF_HOST: "192.168.1.62",
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN: "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test config flow aborts when already configured."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "192.168.1.62",
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN: "token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result2["type"] == FlowResultType.ABORT
|
||||||
|
assert result2["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("side_effect", "error"),
|
||||||
|
[
|
||||||
|
(TedeeClientException("boom."), {CONF_HOST: "invalid_host"}),
|
||||||
|
(
|
||||||
|
TedeeLocalAuthException("boom."),
|
||||||
|
{CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_config_flow_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
side_effect: Exception,
|
||||||
|
error: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Test the config flow errors."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
|
||||||
|
mock_tedee.get_local_bridge.side_effect = side_effect
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "192.168.1.42",
|
||||||
|
CONF_LOCAL_ACCESS_TOKEN: "wrong_token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.FORM
|
||||||
|
assert result2["errors"] == error
|
||||||
|
assert len(mock_tedee.get_local_bridge.mock_calls) == 1
|
48
tests/components/tedee/test_init.py
Normal file
48
tests/components/tedee/test_init.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Test initialization of tedee."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pytedee_async.exception import TedeeAuthException, TedeeClientException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_unload_config_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test loading and unloading the integration."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"side_effect", [TedeeClientException(""), TedeeAuthException("")]
|
||||||
|
)
|
||||||
|
async def test_config_entry_not_ready(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
side_effect: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Test the LaMetric configuration entry not ready."""
|
||||||
|
mock_tedee.get_locks.side_effect = side_effect
|
||||||
|
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_tedee.get_locks.mock_calls) == 1
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
209
tests/components/tedee/test_lock.py
Normal file
209
tests/components/tedee/test_lock.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for tedee lock."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
from pytedee_async.exception import (
|
||||||
|
TedeeClientException,
|
||||||
|
TedeeDataUpdateException,
|
||||||
|
TedeeLocalAuthException,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.lock import (
|
||||||
|
DOMAIN as LOCK_DOMAIN,
|
||||||
|
SERVICE_LOCK,
|
||||||
|
SERVICE_OPEN,
|
||||||
|
SERVICE_UNLOCK,
|
||||||
|
STATE_LOCKING,
|
||||||
|
STATE_UNLOCKING,
|
||||||
|
)
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("init_integration")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lock(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test the tedee lock."""
|
||||||
|
mock_tedee.lock.return_value = None
|
||||||
|
mock_tedee.unlock.return_value = None
|
||||||
|
mock_tedee.open.return_value = None
|
||||||
|
|
||||||
|
state = hass.states.get("lock.lock_1a2b")
|
||||||
|
assert state
|
||||||
|
assert state == snapshot
|
||||||
|
|
||||||
|
entry = entity_registry.async_get(state.entity_id)
|
||||||
|
assert entry
|
||||||
|
assert entry == snapshot
|
||||||
|
assert entry.device_id
|
||||||
|
|
||||||
|
device = device_registry.async_get(entry.device_id)
|
||||||
|
assert device == snapshot
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_LOCK,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_tedee.lock.mock_calls) == 1
|
||||||
|
mock_tedee.lock.assert_called_once_with(12345)
|
||||||
|
state = hass.states.get("lock.lock_1a2b")
|
||||||
|
assert state
|
||||||
|
assert state.state == STATE_LOCKING
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_UNLOCK,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_tedee.unlock.mock_calls) == 1
|
||||||
|
mock_tedee.unlock.assert_called_once_with(12345)
|
||||||
|
state = hass.states.get("lock.lock_1a2b")
|
||||||
|
assert state
|
||||||
|
assert state.state == STATE_UNLOCKING
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_OPEN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_tedee.open.mock_calls) == 1
|
||||||
|
mock_tedee.open.assert_called_once_with(12345)
|
||||||
|
state = hass.states.get("lock.lock_1a2b")
|
||||||
|
assert state
|
||||||
|
assert state.state == STATE_UNLOCKING
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lock_without_pullspring(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test the tedee lock without pullspring."""
|
||||||
|
mock_tedee.lock.return_value = None
|
||||||
|
mock_tedee.unlock.return_value = None
|
||||||
|
mock_tedee.open.return_value = None
|
||||||
|
|
||||||
|
state = hass.states.get("lock.lock_2c3d")
|
||||||
|
assert state
|
||||||
|
assert state == snapshot
|
||||||
|
|
||||||
|
entry = entity_registry.async_get(state.entity_id)
|
||||||
|
assert entry
|
||||||
|
assert entry == snapshot
|
||||||
|
|
||||||
|
assert entry.device_id
|
||||||
|
device = device_registry.async_get(entry.device_id)
|
||||||
|
assert device
|
||||||
|
assert device == snapshot
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
HomeAssistantError,
|
||||||
|
match="Entity lock.lock_2c3d does not support this service.",
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_OPEN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_2c3d",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_tedee.open.mock_calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lock_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test event errors."""
|
||||||
|
mock_tedee.lock.side_effect = TedeeClientException("Boom")
|
||||||
|
with pytest.raises(HomeAssistantError, match="Failed to lock the door. Lock 12345"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_LOCK,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tedee.unlock.side_effect = TedeeClientException("Boom")
|
||||||
|
with pytest.raises(
|
||||||
|
HomeAssistantError, match="Failed to unlock the door. Lock 12345"
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_UNLOCK,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tedee.open.side_effect = TedeeClientException("Boom")
|
||||||
|
with pytest.raises(
|
||||||
|
HomeAssistantError, match="Failed to unlatch the door. Lock 12345"
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
LOCK_DOMAIN,
|
||||||
|
SERVICE_OPEN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "lock.lock_1a2b",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"side_effect",
|
||||||
|
[
|
||||||
|
TedeeClientException("Boom"),
|
||||||
|
TedeeLocalAuthException("Boom"),
|
||||||
|
TimeoutError,
|
||||||
|
TedeeDataUpdateException("Boom"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_update_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tedee: MagicMock,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
side_effect: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Test update failed."""
|
||||||
|
mock_tedee.sync.side_effect = side_effect
|
||||||
|
freezer.tick(timedelta(minutes=10))
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("lock.lock_1a2b")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_UNAVAILABLE
|
Loading…
x
Reference in New Issue
Block a user