core/homeassistant/components/lock/__init__.py

326 lines
10 KiB
Python

"""Component to interface with locks that can be controlled remotely."""
from __future__ import annotations
from datetime import timedelta
from enum import IntFlag
import functools as ft
import logging
import re
from typing import TYPE_CHECKING, Any, final
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CODE,
ATTR_CODE_FORMAT,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
make_entity_service_schema,
)
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType
from . import group as group_pre_import # noqa: F401
if TYPE_CHECKING:
from functools import cached_property
else:
from homeassistant.backports.functools import cached_property
_LOGGER = logging.getLogger(__name__)
ATTR_CHANGED_BY = "changed_by"
CONF_DEFAULT_CODE = "default_code"
DOMAIN = "lock"
SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
LOCK_SERVICE_SCHEMA = make_entity_service_schema({vol.Optional(ATTR_CODE): cv.string})
class LockEntityFeature(IntFlag):
"""Supported features of the lock entity."""
OPEN = 1
# The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5.
# Please use the LockEntityFeature enum instead.
_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1")
PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT}
# mypy: disallow-any-generics
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Track states and offer events for locks."""
component = hass.data[DOMAIN] = EntityComponent[LockEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
component.async_register_entity_service(
SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service"
)
component.async_register_entity_service(
SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service"
)
component.async_register_entity_service(
SERVICE_OPEN,
LOCK_SERVICE_SCHEMA,
"async_handle_open_service",
[LockEntityFeature.OPEN],
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: EntityComponent[LockEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[LockEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
class LockEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes lock entities."""
CACHED_PROPERTIES_WITH_ATTR_ = {
"changed_by",
"code_format",
"is_locked",
"is_locking",
"is_unlocking",
"is_jammed",
"supported_features",
}
class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Base class for lock entities."""
entity_description: LockEntityDescription
_attr_changed_by: str | None = None
_attr_code_format: str | None = None
_attr_is_locked: bool | None = None
_attr_is_locking: bool | None = None
_attr_is_unlocking: bool | None = None
_attr_is_jammed: bool | None = None
_attr_state: None = None
_attr_supported_features: LockEntityFeature = LockEntityFeature(0)
_lock_option_default_code: str = ""
__code_format_cmp: re.Pattern[str] | None = None
@final
@callback
def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]:
"""Add default lock code."""
code: str = data.pop(ATTR_CODE, "")
if not code:
code = self._lock_option_default_code
if self.code_format_cmp and not self.code_format_cmp.match(code):
if TYPE_CHECKING:
assert self.code_format
raise ServiceValidationError(
f"The code for {self.entity_id} doesn't match pattern {self.code_format}",
translation_domain=DOMAIN,
translation_key="add_default_code",
translation_placeholders={
"entity_id": self.entity_id,
"code_format": self.code_format,
},
)
if code:
data[ATTR_CODE] = code
return data
@cached_property
def changed_by(self) -> str | None:
"""Last change triggered by."""
return self._attr_changed_by
@cached_property
def code_format(self) -> str | None:
"""Regex for code format or None if no code is required."""
return self._attr_code_format
@property
@final
def code_format_cmp(self) -> re.Pattern[str] | None:
"""Return a compiled code_format."""
if self.code_format is None:
self.__code_format_cmp = None
return None
if (
not self.__code_format_cmp
or self.code_format != self.__code_format_cmp.pattern
):
self.__code_format_cmp = re.compile(self.code_format)
return self.__code_format_cmp
@cached_property
def is_locked(self) -> bool | None:
"""Return true if the lock is locked."""
return self._attr_is_locked
@cached_property
def is_locking(self) -> bool | None:
"""Return true if the lock is locking."""
return self._attr_is_locking
@cached_property
def is_unlocking(self) -> bool | None:
"""Return true if the lock is unlocking."""
return self._attr_is_unlocking
@cached_property
def is_jammed(self) -> bool | None:
"""Return true if the lock is jammed (incomplete locking)."""
return self._attr_is_jammed
@final
async def async_handle_lock_service(self, **kwargs: Any) -> None:
"""Add default code and lock."""
await self.async_lock(**self.add_default_code(kwargs))
def lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
raise NotImplementedError()
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs))
@final
async def async_handle_unlock_service(self, **kwargs: Any) -> None:
"""Add default code and unlock."""
await self.async_unlock(**self.add_default_code(kwargs))
def unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
raise NotImplementedError()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs))
@final
async def async_handle_open_service(self, **kwargs: Any) -> None:
"""Add default code and open."""
await self.async_open(**self.add_default_code(kwargs))
def open(self, **kwargs: Any) -> None:
"""Open the door latch."""
raise NotImplementedError()
async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
await self.hass.async_add_executor_job(ft.partial(self.open, **kwargs))
@final
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the state attributes."""
state_attr = {}
for prop, attr in PROP_TO_ATTR.items():
if (value := getattr(self, prop)) is not None:
state_attr[attr] = value
return state_attr
@final
@property
def state(self) -> str | None:
"""Return the state."""
if self.is_jammed:
return STATE_JAMMED
if self.is_locking:
return STATE_LOCKING
if self.is_unlocking:
return STATE_UNLOCKING
if (locked := self.is_locked) is None:
return None
return STATE_LOCKED if locked else STATE_UNLOCKED
@cached_property
def supported_features(self) -> LockEntityFeature:
"""Return the list of supported features."""
features = self._attr_supported_features
if type(features) is int: # noqa: E721
new_features = LockEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
async def async_internal_added_to_hass(self) -> None:
"""Call when the sensor entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from entity registry.
Called when the entity registry entry has been updated and before the lock is
added to the state machine.
"""
assert self.registry_entry
if (lock_options := self.registry_entry.options.get(DOMAIN)) and (
custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE)
):
if self.code_format_cmp and self.code_format_cmp.match(
custom_default_lock_code
):
self._lock_option_default_code = custom_default_lock_code
return
self._lock_option_default_code = ""
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())