This commit is contained in:
Victor Alexandrovich Tsyrenschikov
2026-03-30 20:25:42 +05:00
parent 139f9f1bd2
commit 373ed28445
2449 changed files with 53602 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
"""The TTLock integration."""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from aiohttp.web import Request
from homeassistant.components import cloud, persistent_notification, webhook
from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_WEBHOOK_ID,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import CoreState, Event, HomeAssistant
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
issue_registry as ir,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.network import NoURLAvailableError
from .api import TTLockApi
from .const import (
CONF_WEBHOOK_STATUS,
CONF_WEBHOOK_URL,
DOMAIN,
SIGNAL_NEW_DATA,
TT_API,
TT_LOCKS,
)
from .coordinator import LockUpdateCoordinator
from .models import WebhookEvent
from .services import Services
PLATFORMS: list[Platform] = [
Platform.LOCK,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
def setup(hass: HomeAssistant, config: ConfigEntry) -> bool:
"""Set up the TTLock component."""
Services(hass).register()
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up TTLock from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
client = TTLockApi(aiohttp_client.async_get_clientsession(hass), session)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {TT_API: client}
locks = [
LockUpdateCoordinator(hass, client, lock_id)
for lock_id in await client.get_locks()
]
await asyncio.gather(
*[coordinator.async_config_entry_first_refresh() for coordinator in locks]
)
hass.data[DOMAIN][entry.entry_id][TT_LOCKS] = locks
await WebhookHandler(hass, entry).setup()
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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class WebhookHandler:
"""Responsible for setting up/processing webhook data."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Init the thing."""
self.hass = hass
self.entry = entry
async def setup(self) -> None:
"""Actually register the webhook."""
if self.hass.state == CoreState.running:
await self.register_webhook()
else:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self.register_webhook
)
async def get_url(self) -> str:
"""Get the webhook url depending on the setup."""
if cloud.async_active_subscription(self.hass):
if CONF_WEBHOOK_URL not in self.entry.data:
try:
return await cloud.async_create_cloudhook(
self.hass, self.entry.data[CONF_WEBHOOK_ID]
)
except cloud.CloudNotConnected:
return webhook.async_generate_url(
self.hass, self.entry.data[CONF_WEBHOOK_ID]
)
else:
return self.entry.data[CONF_WEBHOOK_URL]
else:
return webhook.async_generate_url(
self.hass, self.entry.data[CONF_WEBHOOK_ID]
)
async def register_webhook(self, event: Event | None = None) -> None:
"""Set up a webhook to receive pushed data."""
if CONF_WEBHOOK_ID not in self.entry.data:
_LOGGER.info("Webhook not found in config entry, creating new one")
data = {**self.entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
self.hass.config_entries.async_update_entry(self.entry, data=data)
try:
webhook_url = await self.get_url()
data = {**self.entry.data, CONF_WEBHOOK_URL: webhook_url}
self.hass.config_entries.async_update_entry(self.entry, data=data)
except NoURLAvailableError:
_LOGGER.exception("Could not find base URL for installation")
ir.async_create_issue(
self.hass,
DOMAIN,
"no_webhook_url",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="no_webhook_url",
)
return
else:
ir.async_delete_issue(self.hass, DOMAIN, "no_webhook_url")
if CONF_WEBHOOK_STATUS not in self.entry.data:
self.async_show_setup_message(webhook_url)
_LOGGER.info("Webhook registered at %s", webhook_url)
# Ensure the webhook is not registered already
webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
webhook_register(
self.hass,
DOMAIN,
"TTLock",
self.entry.data[CONF_WEBHOOK_ID],
self.handle_webhook,
)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self.unregister_webhook
)
async def handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> None:
"""Handle webhook callback."""
success = False
try:
# {'lockId': ['7252408'], 'notifyType': ['1'], 'records': ['[{"lockId":7252408,"electricQuantity":93,"serverDate":1680810180029,"recordTypeFromLock":17,"recordType":7,"success":1,"lockMac":"16:72:4C:CC:01:C4","keyboardPwd":"<digits>","lockDate":1680810186000,"username":"Jonas"}]'], 'admin': ['jonas@lemon.nz'], 'lockMac': ['16:72:4C:CC:01:C4']}
if data := await request.post():
_LOGGER.debug("Got webhook data: %s", data)
for raw_records in data.getall("records", []):
for record in json.loads(raw_records):
async_dispatcher_send(
hass, SIGNAL_NEW_DATA, WebhookEvent.parse_obj(record)
)
success = True
else:
_LOGGER.debug("handle_webhook, empty payload: %s", await request.text())
except ValueError as ex:
_LOGGER.exception("Exception parsing webhook data: %s", ex)
return
if success and CONF_WEBHOOK_STATUS not in self.entry.data:
self.async_dismiss_setup_message()
async def unregister_webhook(self, event: Event | None = None) -> None:
"""Remove the webhook (before stop)."""
webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
def async_show_setup_message(self, uri: str) -> None:
"""Display persistent notification with setup information."""
persistent_notification.async_create(
self.hass, f"Webhook url: {uri}", "TTLock Setup", self.entry.entry_id
)
def async_dismiss_setup_message(self) -> None:
"""Dismiss persistent notification."""
data = {**self.entry.data, CONF_WEBHOOK_STATUS: True}
self.hass.config_entries.async_update_entry(self.entry, data=data)
persistent_notification.async_dismiss(self.hass, self.entry.entry_id)

View File

@@ -0,0 +1,345 @@
"""API for TTLock bound to Home Assistant OAuth."""
import asyncio
from collections.abc import Mapping
from hashlib import md5
import json
import logging
from secrets import token_hex
import time
from typing import Any, cast
from urllib.parse import urljoin
from aiohttp import ClientResponse, ClientSession
from homeassistant.components.application_credentials import AuthImplementation
from homeassistant.helpers import config_entry_oauth2_flow
from .models import (
AddPasscodeConfig,
Features,
Lock,
LockRecord,
LockState,
PassageModeConfig,
Passcode,
Sensor,
)
_LOGGER = logging.getLogger(__name__)
GW_LOCK = asyncio.Lock()
class RequestFailed(Exception):
"""Exception when TTLock API returns an error."""
pass
class TTLockAuthImplementation(
AuthImplementation,
):
"""TTLock Local OAuth2 implementation."""
async def login(self, username: str, password: str) -> dict:
"""Make a token request."""
return await self._token_request(
{
"username": username,
"password": md5(password.encode("utf-8")).hexdigest(),
}
)
async def _async_refresh_token(self, token: dict) -> dict:
"""Refresh tokens."""
new_token = await self._token_request(
{
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": token["refresh_token"],
}
)
return {**token, **new_token}
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve the authorization code to tokens."""
return dict(external_data)
class TTLockApi:
"""Provide TTLock authentication tied to an OAuth2 based config entry."""
BASE = "https://euapi.ttlock.com/v3/"
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize TTLock auth."""
self._web_session = websession
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token["access_token"]
async def _add_auth(self, **kwargs) -> dict:
kwargs["clientId"] = self._oauth_session.implementation.client_id
kwargs["accessToken"] = await self.async_get_access_token()
kwargs["date"] = str(round(time.time() * 1000))
return kwargs
async def _parse_resp(self, resp: ClientResponse, log_id: str) -> Mapping[str, Any]:
if resp.status >= 400:
body = await resp.text()
_LOGGER.debug(
"[%s] Request failed: status=%s, body=%s", log_id, resp.status, body
)
else:
body = await resp.json()
_LOGGER.debug(
"[%s] Received response: status=%s: body=%s", log_id, resp.status, body
)
resp.raise_for_status()
res = cast(dict, await resp.json())
if res.get("errcode", 0) != 0:
_LOGGER.debug("[%s] API returned: %s", log_id, res)
raise RequestFailed(f"API returned: {res}")
return cast(dict, await resp.json())
async def get(self, path: str, **kwargs: Any) -> Mapping[str, Any]:
"""Make GET request to the API with kwargs as query params."""
log_id = token_hex(2)
url = urljoin(self.BASE, path)
_LOGGER.debug("[%s] Sending request to %s with args=%s", log_id, url, kwargs)
resp = await self._web_session.get(
url,
params=await self._add_auth(**kwargs),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
return await self._parse_resp(resp, log_id)
async def post(self, path: str, **kwargs: Any) -> Mapping[str, Any]:
"""Make GET request to the API with kwargs as query params."""
log_id = token_hex(2)
url = urljoin(self.BASE, path)
_LOGGER.debug("[%s] Sending request to %s with args=%s", log_id, url, kwargs)
resp = await self._web_session.post(
url,
params=await self._add_auth(),
data=kwargs,
)
return await self._parse_resp(resp, log_id)
async def get_locks(self) -> list[int]:
"""Enumerate all locks in the account."""
res = await self.get("lock/list", pageNo=1, pageSize=1000)
def lock_connectable(lock) -> bool:
has_gateway = lock.get("hasGateway") != 0
has_wifi = Features.wifi in Features.from_feature_value(
lock.get("featureValue")
)
return has_gateway or has_wifi
return [lock["lockId"] for lock in res["list"] if lock_connectable(lock)]
async def get_lock(self, lock_id: int) -> Lock:
"""Get a lock by ID."""
res = await self.get("lock/detail", lockId=lock_id)
return Lock.parse_obj(res)
async def get_sensor(self, lock_id: int) -> Sensor | None:
"""Get the Sensor."""
try:
res = await self.get("doorSensor/query", lockId=lock_id)
return Sensor.parse_obj(res)
except RequestFailed:
# Janky but the API doesn't return different errors if the sensor is missing or there's some other problem
return None
async def get_lock_state(self, lock_id: int) -> LockState:
"""Get the state of a lock."""
async with GW_LOCK:
res = await self.get("lock/queryOpenState", lockId=lock_id)
return LockState.parse_obj(res)
async def get_lock_passage_mode_config(self, lock_id: int) -> PassageModeConfig:
"""Get the passage mode configuration of a lock."""
res = await self.get("lock/getPassageModeConfig", lockId=lock_id)
return PassageModeConfig.parse_obj(res)
async def lock(self, lock_id: int) -> bool:
"""Try to lock the lock."""
async with GW_LOCK:
res = await self.get("lock/lock", lockId=lock_id)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error("Failed to lock %s: %s", lock_id, res["errmsg"])
return False
return True
async def unlock(self, lock_id: int) -> bool:
"""Try to unlock the lock."""
async with GW_LOCK:
res = await self.get("lock/unlock", lockId=lock_id)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error("Failed to unlock %s: %s", lock_id, res["errmsg"])
return False
return True
async def set_passage_mode(self, lock_id: int, config: PassageModeConfig) -> bool:
"""Configure passage mode."""
async with GW_LOCK:
res = await self.post(
"lock/configPassageMode",
lockId=lock_id,
type=2, # via gateway
passageMode=1 if config.enabled else 2,
autoUnlock=1 if config.auto_unlock else 2,
isAllDay=1 if config.all_day else 2,
startDate=config.start_minute,
endDate=config.end_minute,
weekDays=json.dumps(config.week_days),
)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error("Failed to unlock %s: %s", lock_id, res["errmsg"])
return False
return True
async def add_passcode(self, lock_id: int, config: AddPasscodeConfig) -> bool:
"""Add new passcode."""
async with GW_LOCK:
res = await self.post(
"keyboardPwd/add",
lockId=lock_id,
addType=2, # via gateway
keyboardPwd=config.passcode,
keyboardPwdName=config.passcode_name,
keyboardPwdType=3, # Only temporary passcode supported
startDate=config.start_minute,
endDate=config.end_minute,
)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error(
"Failed to create passcode for %s: %s", lock_id, res["errmsg"]
)
return False
return True
async def list_passcodes(self, lock_id: int) -> list[Passcode]:
"""Get currently configured passcodes from lock."""
res = await self.get(
"lock/listKeyboardPwd", lockId=lock_id, pageNo=1, pageSize=100
)
return [Passcode.parse_obj(passcode) for passcode in res["list"]]
async def delete_passcode(self, lock_id: int, passcode_id: int) -> bool:
"""Delete a passcode from lock."""
async with GW_LOCK:
resDel = await self.post(
"keyboardPwd/delete",
lockId=lock_id,
deleteType=2, # via gateway
keyboardPwdId=passcode_id,
)
if "errcode" in resDel and resDel["errcode"] != 0:
_LOGGER.error(
"Failed to delete passcode for %s: %s",
lock_id,
resDel["errmsg"],
)
return False
return True
async def set_auto_lock(self, lock_id: int, seconds: int) -> bool:
"""Set the AutoLock feature of the lock."""
async with GW_LOCK:
res = await self.post(
"lock/setAutoLockTime",
lockId=lock_id,
seconds=seconds,
type=2,
)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error(
"Failed to update autolock",
lock_id,
res["errmsg"],
)
return False
return True
async def set_lock_sound(self, lock_id: int, value: int) -> bool:
"""Set the LockSound feature of the lock."""
async with GW_LOCK:
res = await self.post(
"lock/updateSetting",
lockId=lock_id,
value=value,
type=6,
changeType=2,
)
if "errcode" in res and res["errcode"] != 0:
_LOGGER.error(
"Failed to update sound setting",
lock_id,
res["errmsg"],
)
return False
return True
async def get_lock_records(
self,
lock_id: int,
start_date: int | None = None,
end_date: int | None = None,
page_no: int = 1,
page_size: int = 20,
) -> list[LockRecord]:
"""Get the operation records for a lock."""
params = {
"lockId": lock_id,
"pageNo": page_no,
"pageSize": min(page_size, 200),
"date": str(round(time.time() * 1000)),
}
if start_date is not None:
params["startDate"] = start_date
if end_date is not None:
params["endDate"] = end_date
res = await self.get("lockRecord/list", **params)
# Serialize each record to ensure datetime objects are handled
return [LockRecord.parse_obj(record) for record in res["list"]]

View File

@@ -0,0 +1,26 @@
"""application_credentials platform the TTLock integration."""
from homeassistant.components.application_credentials import (
AuthorizationServer,
ClientCredential,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from .api import TTLockAuthImplementation
from .const import OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return TTLockAuthImplementation(
hass,
auth_domain,
credential,
AuthorizationServer(
authorize_url="",
token_url=OAUTH2_TOKEN,
),
)

View File

@@ -0,0 +1,63 @@
"""Support for iCloud sensors."""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import lock_coordinators, sensor_present
from .entity import BaseLockEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all the locks for the config entry."""
async_add_entities(
[
entity
for coordinator in lock_coordinators(hass, entry)
for entity in (
PassageMode(coordinator),
Sensor(coordinator)
if sensor_present(coordinator.data.sensor)
else None,
)
if entity is not None
]
)
class Sensor(BaseLockEntity, BinarySensorEntity):
"""Current sensor state."""
_attr_device_class = BinarySensorDeviceClass.DOOR
def _update_from_coordinator(self) -> None:
"""Fetch state of device."""
self._attr_name = f"{self.coordinator.data.name} Sensor"
self._attr_is_on = (
bool(self.coordinator.data.sensor.opened)
if self.coordinator.data.sensor
else False
)
class PassageMode(BaseLockEntity, BinarySensorEntity):
"""Current passage mode state."""
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Passage Mode"
self._attr_is_on = self.coordinator.data.passage_mode_active()

View File

@@ -0,0 +1,51 @@
"""Config flow for TTLock."""
import logging
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
class TTLockAuthFlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
): # type: ignore[call-arg]
"""Config flow to handle TTLock OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Create an entry for auth."""
# Flow has been triggered by external data
errors = {}
if user_input is not None:
session = await self.flow_impl.login(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if "errmsg" in session:
errors["base"] = session["errmsg"]
else:
self.external_data = session
return await self.async_step_creation()
return self.async_show_form(
step_id="auth",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)

View File

@@ -0,0 +1,26 @@
"""Constants for the TTLock integration."""
DOMAIN = "ttlock"
TT_API = "api"
TT_LOCKS = "locks"
OAUTH2_TOKEN = "https://euapi.ttlock.com/oauth2/token"
CONF_WEBHOOK_URL = "webhook_url"
CONF_WEBHOOK_STATUS = "webhook_status"
SIGNAL_NEW_DATA = f"{DOMAIN}.data_received"
CONF_AUTO_UNLOCK = "auto_unlock"
CONF_ALL_DAY = "all_day"
CONF_START_TIME = "start_time"
CONF_END_TIME = "end_time"
CONF_WEEK_DAYS = "days"
CONF_SECONDS = "seconds"
SVC_CONFIG_AUTOLOCK = "configure_autolock"
SVC_CONFIG_PASSAGE_MODE = "configure_passage_mode"
SVC_CREATE_PASSCODE = "create_passcode"
SVC_CLEANUP_PASSCODES = "cleanup_passcodes"
SVC_LIST_PASSCODES = "list_passcodes"
SVC_LIST_RECORDS = "list_records"

View File

@@ -0,0 +1,324 @@
"""Provides the TTLock LockUpdateCoordinator."""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
import logging
from typing import TypeGuard
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt
from .api import TTLockApi
from .const import DOMAIN, SIGNAL_NEW_DATA, TT_LOCKS
from .models import Features, PassageModeConfig, SensorState, State, WebhookEvent
_LOGGER = logging.getLogger(__name__)
@dataclass
class SensorData:
"""Internal state of the optional door sensor."""
opened: bool | None = None
battery: int | None = None
last_fetched: datetime | None = None
@property
def present(self) -> bool:
"""To indicate if a sensor is installed."""
return self.battery is not None
@dataclass
class LockState:
"""Internal state of the lock as managed by the co-oridinator."""
name: str
mac: str
model: str | None = None
battery_level: int | None = None
hardware_version: str | None = None
firmware_version: str | None = None
features: Features = Features.from_feature_value(None)
locked: bool | None = None
action_pending: bool = False
last_user: str | None = None
last_reason: str | None = None
lock_sound: bool | None = None
sensor: SensorData | None = None
auto_lock_seconds: int | None = None
passage_mode_config: PassageModeConfig | None = None
def passage_mode_active(self, current_date: datetime = dt.now()) -> bool:
"""Check if passage mode is currently active."""
if self.passage_mode_config and self.passage_mode_config.enabled:
current_day = current_date.isoweekday()
if current_day in self.passage_mode_config.week_days:
if self.passage_mode_config.all_day:
return True
current_minute = current_date.hour * 60 + current_date.minute
if (
self.passage_mode_config.start_minute
<= current_minute
< self.passage_mode_config.end_minute
):
# Active by schedule
return True
return False
def auto_lock_delay(self, current_date: datetime) -> int | None:
"""Return the auto-lock delay in seconds, or None if auto-lock is currently disabled."""
if self.auto_lock_seconds is None or self.auto_lock_seconds <= 0:
return None
if self.passage_mode_active(current_date):
return None
return self.auto_lock_seconds
def sensor_present(instance: SensorData | None) -> TypeGuard[SensorData]:
"""Check if a sensor is present."""
return instance is not None and instance.present
@contextmanager
def lock_action(controller: LockUpdateCoordinator):
"""Wrap a lock action so that in-progress state is managed correctly."""
controller.data.action_pending = True
controller.async_update_listeners()
try:
yield
finally:
controller.data.action_pending = False
controller.async_update_listeners()
def lock_coordinators(hass: HomeAssistant, entry: ConfigEntry):
"""Help with entity setup."""
coordinators: list[LockUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id][
TT_LOCKS
]
yield from coordinators
def coordinator_for(
hass: HomeAssistant, entity_id: str
) -> LockUpdateCoordinator | None:
"""Given an entity_id, return the coordinator for that entity."""
for entry in hass.config_entries.async_entries(DOMAIN):
for coordinator in lock_coordinators(hass, entry):
for entity in coordinator.entities:
if entity.entity_id == entity_id:
return coordinator
return None
class LockUpdateCoordinator(DataUpdateCoordinator[LockState]):
"""Class to manage fetching Toon data from single endpoint."""
def __init__(self, hass: HomeAssistant, api: TTLockApi, lock_id: int) -> None:
"""Initialize the update co-ordinator for a single lock."""
self.api = api
self.lock_id = lock_id
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15)
)
async_dispatcher_connect(self.hass, SIGNAL_NEW_DATA, self._process_webhook_data)
async def _async_update_data(self) -> LockState:
try:
details = await self.api.get_lock(self.lock_id)
new_data = deepcopy(self.data) or LockState(
name=details.name,
mac=details.mac,
model=details.model,
features=Features.from_feature_value(details.featureValue),
)
# update mutable attributes
new_data.name = details.name
new_data.battery_level = details.battery_level
new_data.hardware_version = details.hardwareRevision
new_data.firmware_version = details.firmwareRevision
if Features.door_sensor in new_data.features:
# make sure we have a placeholder for sensor state if the lock supports it
if new_data.sensor is None:
new_data.sensor = SensorData()
# only fetch sensor metadata once a day
if (
new_data.sensor.last_fetched is None
or new_data.sensor.last_fetched < dt.now() - timedelta(days=1)
):
sensor = await self.api.get_sensor(self.lock_id)
new_data.sensor.last_fetched = dt.now()
if sensor:
new_data.sensor.battery = sensor.battery_level
else:
new_data.sensor = None
if new_data.locked is None:
try:
state = await self.api.get_lock_state(self.lock_id)
new_data.locked = state.locked == State.locked
if sensor_present(new_data.sensor):
new_data.sensor.opened = state.opened == SensorState.opened
except Exception:
pass
new_data.auto_lock_seconds = details.autoLockTime
new_data.lock_sound = bool(details.lockSound)
new_data.passage_mode_config = await self.api.get_lock_passage_mode_config(
self.lock_id
)
return new_data
except Exception as err:
raise UpdateFailed(err) from err
@callback
def _process_webhook_data(self, event: WebhookEvent):
"""Update data."""
if event.id != self.lock_id:
return
_LOGGER.debug("Lock %s received %s", self.unique_id, event)
if not event.success:
return
if not self.data:
return
new_data = deepcopy(self.data)
new_data.battery_level = event.battery_level
if state := event.state:
if state.locked == State.locked:
new_data.locked = True
elif state.locked == State.unlocked:
new_data.locked = False
self._handle_auto_lock(event.lock_ts, event.server_ts)
if state.locked is not None:
new_data.last_user = event.user
new_data.last_reason = event.event.description
if new_data.sensor and new_data.sensor.present and event.sensorState:
if event.sensorState.opened == SensorState.opened:
new_data.sensor.opened = True
if event.sensorState.opened == SensorState.closed:
new_data.sensor.opened = False
new_data.locked = True
new_data.last_reason = "Door Closed"
_LOGGER.debug("Assuming auto-locked via sensor")
self.async_set_updated_data(new_data)
def _handle_auto_lock(self, lock_ts: datetime, server_ts: datetime):
"""Handle auto-locking the lock."""
auto_lock_delay = self.data.auto_lock_delay(lock_ts)
computed_msg_delay = max(0, (server_ts - lock_ts).total_seconds())
if auto_lock_delay is None:
_LOGGER.debug("Auto-lock is disabled")
return
async def _auto_locked(seconds: int, offset: float = 0):
if seconds > 0 and (seconds - offset) > 0:
await asyncio.sleep(seconds - offset)
new_data = deepcopy(self.data)
new_data.locked = True
new_data.last_reason = "Auto Lock"
_LOGGER.debug("Assuming lock auto locked after %s seconds", auto_lock_delay)
self.async_set_updated_data(new_data)
self.hass.create_task(_auto_locked(auto_lock_delay, computed_msg_delay))
@property
def unique_id(self) -> str:
"""Unique ID prefix for all entities for the lock."""
return f"{DOMAIN}-{self.lock_id}"
@property
def device_info(self) -> DeviceInfo:
"""Device info for the lock."""
return DeviceInfo(
identifiers={(DOMAIN, self.data.mac)},
manufacturer="TT Lock",
model=self.data.model,
name=self.data.name,
sw_version=self.data.firmware_version,
hw_version=self.data.hardware_version,
)
@property
def entities(self) -> list[Entity]:
"""Entities belonging to this co-ordinator."""
return [
callback.__self__
for callback, _ in list(self._listeners.values())
if isinstance(callback.__self__, Entity)
]
def as_dict(self) -> dict:
"""Serialize for diagnostics."""
return {
"unique_id": self.unique_id,
"device": asdict(self.data),
"entities": [
self.hass.states.get(entity.entity_id).as_dict()
for entity in self.entities
],
}
async def lock(self) -> None:
"""Try to lock the lock."""
with lock_action(self):
res = await self.api.lock(self.lock_id)
if res:
self.data.locked = True
async def unlock(self) -> None:
"""Try to unlock the lock."""
with lock_action(self):
res = await self.api.unlock(self.lock_id)
if res:
self.data.locked = False
async def set_auto_lock(self, on: bool) -> None:
"""Turn on/off Autolock."""
seconds = 10 if on else 0
res = await self.api.set_auto_lock(self.lock_id, seconds)
if res:
self.data.auto_lock_seconds = seconds
self.async_update_listeners()
async def set_lock_sound(self, on: bool) -> None:
"""Turn on/off lock sound."""
value = 1 if on else 2
res = await self.api.set_lock_sound(self.lock_id, value)
if res:
self.data.lock_sound = on
self.async_update_listeners()

View File

@@ -0,0 +1,40 @@
"""Diagnostics support for Tractive."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN, TT_LOCKS
TO_REDACT = {
"token",
"lockKey",
"aesKeyStr",
"adminPwd",
"deletePwd",
"noKeyPwd",
"lockData",
"webhook_url",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
diagnostics_data = async_redact_data(
{
"config_entry": config_entry.as_dict(),
"locks": [
coordinator.as_dict()
for coordinator in hass.data[DOMAIN][config_entry.entry_id][TT_LOCKS]
],
},
TO_REDACT,
)
return diagnostics_data

View File

@@ -0,0 +1,39 @@
"""Base entity class for TTLock integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import LockUpdateCoordinator
class BaseLockEntity(CoordinatorEntity[LockUpdateCoordinator], ABC):
"""Abstract base class for lock entity."""
coordinator: LockUpdateCoordinator
def __init__(
self,
coordinator: LockUpdateCoordinator,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = (
f"{coordinator.unique_id}-{self.__class__.__name__.lower()}"
)
self._update_from_coordinator()
# self.entity_description = description
@abstractmethod
def _update_from_coordinator(self) -> None:
pass
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_from_coordinator()
self.async_write_ha_state()

View File

@@ -0,0 +1,48 @@
"""The actual lock part of the locks."""
from __future__ import annotations
from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import lock_coordinators
from .entity import BaseLockEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all the locks for the config entry."""
async_add_entities(
[Lock(coordinator) for coordinator in lock_coordinators(hass, entry)]
)
class Lock(BaseLockEntity, LockEntity):
"""The entity object for a lock."""
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = self.coordinator.data.name
self._attr_is_locked = self.coordinator.data.locked
self._attr_is_locking = (
self.coordinator.data.action_pending and not self.coordinator.data.locked
)
self._attr_is_unlocking = (
self.coordinator.data.action_pending and self.coordinator.data.locked
)
async def async_lock(self, **kwargs: Any) -> None:
"""Try to lock the lock."""
await self.coordinator.lock()
async def async_unlock(self, **kwargs: Any) -> None:
"""Try to unlock the lock."""
await self.coordinator.unlock()

View File

@@ -0,0 +1,20 @@
{
"domain": "ttlock",
"name": "TTLock",
"after_dependencies": ["cloud"],
"codeowners": ["@jbergler"],
"config_flow": true,
"dependencies": [
"application_credentials",
"persistent_notification",
"webhook"
],
"documentation": "https://github.com/jbergler/hass-ttlock",
"homekit": {},
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jbergler/hass-ttlock/issues",
"requirements": ["pydantic"],
"ssdp": [],
"version": "v0.7.2",
"zeroconf": []
}

View File

@@ -0,0 +1,397 @@
"""Models for parsing the TTLock API data."""
from collections import namedtuple
from datetime import datetime
from enum import Enum, IntEnum, IntFlag, auto
from typing import Optional
try:
from pydantic import BaseModel, Field, validator
except ImportError:
from pydantic import BaseModel, Field, validator
from homeassistant.util import dt
class EpochMs(datetime):
"""Parse millisecond epoch into a local datetime."""
@classmethod
def __get_validators__(cls):
"""Return validator."""
yield cls.validate
@classmethod
def validate(cls, v):
"""Use homeassistant time helpers to parse epoch."""
return dt.as_local(dt.utc_from_timestamp(v / 1000))
class OnOff(Enum):
"""Tri-state bool for fields that are on/off."""
unknown = 0
on = 1
off = 2
def __bool__(self) -> bool:
"""Overload truthyness to 'on'."""
return self == OnOff.on
class OpenDirection(Enum):
"""Tri-state for door open direction."""
unknown = 0
left = 1
right = 2
class State(Enum):
"""State of the lock."""
locked = 0
unlocked = 1
unknown = 2
class SensorState(Enum):
"""State of the sensor."""
opened = 0
closed = 1
unknown = None
class Lock(BaseModel):
"""Lock details."""
id: int = Field(..., alias="lockId")
type: str = Field(..., alias="lockName")
name: str = Field("Lock", alias="lockAlias")
mac: str = Field(..., alias="lockMac")
battery_level: int | None = Field(None, alias="electricQuantity")
featureValue: str | None = None
timezoneRawOffset: int = 0
model: str | None = Field(None, alias="modelNum")
hardwareRevision: str | None = None
firmwareRevision: str | None = None
autoLockTime: int | None = Field(None, alias="autoLockTime")
lockSound: OnOff = OnOff.unknown
privacyLock: OnOff = OnOff.unknown
tamperAlert: OnOff = OnOff.unknown
resetButton: OnOff = OnOff.unknown
openDirection: OpenDirection = OpenDirection.unknown
passageMode: OnOff = OnOff.unknown
passageModeAutoUnlock: OnOff = OnOff.unknown
date: int
# sensitive fields
noKeyPwd: str = Field(alias="adminPwd")
class Sensor(BaseModel):
"""sensor details."""
id: int = Field(..., alias="doorSensorId")
name: str = Field("Sensor", alias="name")
battery_level: int | None = Field(None, alias="electricQuantity")
mac: str = Field(..., alias="mac")
class LockState(BaseModel):
"""Lock state."""
locked: State | None = Field(State.unknown, alias="state")
opened: SensorState | None = Field(SensorState.unknown, alias="sensorState")
class PassageModeConfig(BaseModel):
"""The passage mode configuration of the lock."""
enabled: OnOff = Field(OnOff.unknown, alias="passageMode")
start_minute: int = Field(0, alias="startDate")
end_minute: int = Field(0, alias="endDate")
all_day: OnOff = Field(OnOff.unknown, alias="isAllDay")
week_days: list[int] = Field([], alias="weekDays") # monday = 1, sunday = 7
auto_unlock: OnOff = Field(OnOff.unknown, alias="autoUnlock")
@validator("start_minute", pre=True, always=True)
def _set_start_minute(cls, start_minute: int | None) -> int:
return start_minute or 0
@validator("end_minute", pre=True, always=True)
def _set_end_minute(cls, end_minute: int | None) -> int:
return end_minute or 0
class PasscodeType(IntEnum):
"""Type of passcode."""
unknown = 0
permanent = 2
temporary = 3
class Passcode(BaseModel):
"""A single passcode on a lock."""
id: int = Field(None, alias="keyboardPwdId")
passcode: str = Field(None, alias="keyboardPwd")
name: str = Field(None, alias="keyboardPwdName")
type: PasscodeType = Field(None, alias="keyboardPwdType")
start_date: EpochMs = Field(None, alias="startDate")
end_date: EpochMs = Field(None, alias="endDate")
@property
def expired(self) -> bool:
"""True if the passcode expired."""
if self.type == PasscodeType.temporary:
return self.end_date < dt.now()
# Assume not
return False
class RecordType(IntEnum):
"""Type of lock record."""
BLUETOOTH_UNLOCK = 1
PASSWORD_UNLOCK = 4
PARKING_LOCK = 5
PARKING_SPACE_LOCK_AND_LOWERING = 6
IC_CARD_UNLOCK = 7
FINGERPRINT_UNLOCK = 8
BRACELET_UNLOCK = 9
MECHANICAL_KEY_UNLOCK = 10
BLUETOOTH_LOCK = 11
GATEWAY_UNLOCK = 12
ILLEGAL_UNLOCKING = 29
DOOR_MAGNET_CLOSED = 30
DOOR_SENSOR_OPEN = 31
OPEN_DOOR_FROM_INSIDE = 32
FINGERPRINT_LOCK = 33
PASSWORD_LOCK = 34
IC_CARD_LOCK = 35
MECHANICAL_KEY_LOCK = 36
APP_BUTTON_CONTROL = 37
POST_OFFICE_LOCAL_MAIL = 42
POST_OFFICE_OUT_OF_TOWN_MAIL = 43
ANTI_THEFT_ALARM = 44
AUTOMATIC_LOCK_TIMEOUT = 45
UNLOCK_BUTTON = 46
LOCK_BUTTON = 47
SYSTEM_LOCKED = 48
HOTEL_CARD_UNLOCK = 49
HIGH_TEMPERATURE_UNLOCK = 50
DELETED_CARD_UNLOCK = 51
LOCK_WITH_APP = 52
LOCK_WITH_PASSWORD = 53
CAR_LEAVES = 54
REMOTE_CONTROL = 55
QR_CODE_UNLOCK_SUCCESS = 57
QR_CODE_UNLOCK_FAILED_EXPIRED = 58
OPEN_ANTI_LOCK = 59
CLOSE_ANTI_LOCK = 60
QR_CODE_LOCK_SUCCESS = 61
QR_CODE_UNLOCK_FAILED_LOCKED = 62
AUTOMATIC_UNLOCKING_NORMAL_OPEN_TIME = 63
DOOR_NOT_CLOSED_ALARM = 64
UNLOCK_TIMEOUT = 65
LOCKOUT_TIMEOUT = 66
THREE_D_FACE_UNLOCK_SUCCESS = 67
THREE_D_FACE_UNLOCK_FAILED_LOCKED = 68
THREE_D_FACE_LOCK = 69
THREE_D_FACE_RECOGNITION_FAILED_EXPIRED = 71
APP_AUTHORIZATION_BUTTON_UNLOCK_SUCCESS = 75
GATEWAY_AUTHORIZATION_KEY_UNLOCK_SUCCESS = 76
DUAL_AUTHENTICATION_BLUETOOTH_UNLOCK_SUCCESS = 77
DUAL_AUTHENTICATION_PASSWORD_UNLOCK_SUCCESS = 78
DUAL_AUTHENTICATION_FINGERPRINT_UNLOCK_SUCCESS = 79
DUAL_AUTHENTICATION_IC_CARD_UNLOCK_SUCCESS = 80
DUAL_AUTHENTICATION_FACE_CARD_UNLOCK_SUCCESS = 81
DUAL_AUTHENTICATION_REMOTE_UNLOCK_SUCCESS = 82
DUAL_AUTHENTICATION_PALM_VEIN_UNLOCK_SUCCESS = 83
PALM_VEIN_UNLOCK_SUCCESS = 84
PALM_VEIN_UNLOCK_FAILED_LOCKED = 85
PALM_VEIN_ATRESIA = 86
PALM_VEIN_OPENING_FAILED_EXPIRED = 88
IC_CARD_UNLOCK_FAILED = 91
ADMINISTRATOR_PASSWORD_UNLOCK = 92
class LockRecord(BaseModel):
"""A single record entry from a lock."""
id: int = Field(None, alias="recordId")
lock_id: int = Field(None, alias="lockId")
record_type: RecordType = Field(None, alias="recordType")
success: bool = Field(...)
username: Optional[str] = Field(None)
keyboard_pwd: str | None = Field(None, alias="keyboardPwd")
lock_date: EpochMs = Field(None, alias="lockDate")
server_date: EpochMs = Field(None, alias="serverDate")
class AddPasscodeConfig(BaseModel):
"""The passcode creation configuration."""
passcode: str = Field(None, alias="passcode")
passcode_name: str = Field(None, alias="passcodeName")
start_minute: int = Field(0, alias="startDate")
end_minute: int = Field(0, alias="endDate")
class Action(Enum):
"""Lock action from an event."""
unknown = auto()
lock = auto()
unlock = auto()
open = auto()
close = auto()
EventDescription = namedtuple("EventDescription", ["action", "description"])
class Event:
"""Event description for lock events."""
def __init__(self, event_id: int):
"""Initialize from int event id."""
self._value_ = event_id
EVENTS: dict[int, EventDescription] = {
1: EventDescription(Action.unlock, "unlock by app"),
4: EventDescription(Action.unlock, "unlock by passcode"),
7: EventDescription(Action.unlock, "unlock by IC card"),
8: EventDescription(Action.unlock, "unlock by fingerprint"),
9: EventDescription(Action.unlock, "unlock by wrist strap"),
10: EventDescription(Action.unlock, "unlock by Mechanical key"),
11: EventDescription(Action.lock, "lock by app"),
12: EventDescription(Action.unlock, "unlock by gateway"),
29: EventDescription(Action.unknown, "apply some force on the Lock"),
30: EventDescription(Action.close, "Door sensor closed"),
31: EventDescription(Action.open, "Door sensor open"),
32: EventDescription(Action.open, "open from inside"),
33: EventDescription(Action.lock, "lock by fingerprint"),
34: EventDescription(Action.lock, "lock by passcode"),
35: EventDescription(Action.lock, "lock by IC card"),
36: EventDescription(Action.lock, "lock by Mechanical key"),
37: EventDescription(Action.unknown, "Remote Control"),
42: EventDescription(Action.unknown, "received new local mail"),
43: EventDescription(Action.unknown, "received new other cities' mail"),
44: EventDescription(Action.unknown, "Tamper alert"),
45: EventDescription(Action.lock, "Auto Lock"),
46: EventDescription(Action.unlock, "unlock by unlock key"),
47: EventDescription(Action.lock, "lock by lock key"),
48: EventDescription(
Action.unknown,
"System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times)",
),
49: EventDescription(Action.unlock, "unlock by hotel card"),
50: EventDescription(Action.unlock, "unlocked due to the high temperature"),
51: EventDescription(Action.unknown, "Try to unlock with a deleted card"),
52: EventDescription(Action.unknown, "Dead lock with APP"),
53: EventDescription(Action.unknown, "Dead lock with passcode"),
54: EventDescription(Action.unknown, "The car left (for parking lock)"),
55: EventDescription(Action.unlock, "unlock with key fob"),
57: EventDescription(Action.unlock, "unlock with QR code success"),
58: EventDescription(
Action.unknown, "Unlock with QR code failed, it's expired"
),
59: EventDescription(Action.unknown, "Double locked"),
60: EventDescription(Action.unknown, "Cancel double lock"),
61: EventDescription(Action.lock, "Lock with QR code success"),
62: EventDescription(
Action.unknown, "Lock with QR code failed, the lock is double locked"
),
63: EventDescription(Action.unlock, "auto unlock at passage mode"),
}
@property
def _info(self) -> EventDescription:
return self.EVENTS.get(
self._value_, EventDescription(Action.unknown, "unknown")
)
@property
def action(self) -> Action:
"""The action this event represents."""
return self._info.action
@property
def description(self) -> str:
"""A description of the event."""
return self._info.description
@classmethod
def __get_validators__(cls):
"""Validate generator for pydantic type."""
yield cls.validate
@classmethod
def validate(cls, v):
"""Validate for pydantic type."""
if not isinstance(v, int):
raise TypeError("int required")
if v not in cls.EVENTS:
raise ValueError("invalid record")
return cls(v)
def __repr__(self):
"""Representation of the event."""
return f"Event({self._info})"
class WebhookEvent(BaseModel):
"""Event from the API (via webhook)."""
id: int = Field(..., alias="lockId")
mac: str = Field(..., alias="lockMac")
battery_level: int | None = Field(None, alias="electricQuantity")
server_ts: EpochMs = Field(..., alias="serverDate")
lock_ts: EpochMs = Field(..., alias="lockDate")
event: Event = Field(..., alias="recordType")
user: str = Field(None, alias="username")
success: bool
# keyboardPwd - ignore for now
@property
def state(self) -> LockState:
"""The end state of the lock after this event."""
if self.success and self.event.action == Action.lock:
return LockState(state=State.locked)
elif self.success and self.event.action == Action.unlock:
return LockState(state=State.unlocked)
return LockState(state=None)
@property
def sensorState(self) -> LockState:
"""The end state of the sensor after this event."""
if self.success and self.event.action == Action.close:
return LockState(state=State.locked, sensorState=SensorState.closed)
elif self.success and self.event.action == Action.open:
return LockState(sensorState=SensorState.opened)
return LockState(sensorState=None)
class Features(IntFlag):
"""Parses the features bitmask from the hex string in the api response."""
# Docs: https://euopen.ttlock.com/document/doc?urlName=cloud%2Flock%2FfeatureValueEn.html.
lock_remotely = 2**8
unlock_via_gateway = 2**10
door_sensor = 2**13
passage_mode = 2**22
wifi = 2**56
@classmethod
def from_feature_value(cls, value: str | None):
"""Parse the hex feature_value string."""
return Features(int(value, 16)) if value else Features(0)

View File

@@ -0,0 +1,112 @@
"""Support for iCloud sensors."""
from __future__ import annotations
import logging
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import lock_coordinators, sensor_present
from .entity import BaseLockEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all the locks for the config entry."""
async_add_entities(
[
entity
for coordinator in lock_coordinators(hass, entry)
for entity in (
LockBattery(coordinator),
LockOperator(coordinator),
LockTrigger(coordinator),
SensorBattery(coordinator)
if sensor_present(coordinator.data.sensor)
else None,
)
if entity is not None
]
)
class LockBattery(BaseLockEntity, SensorEntity):
"""Representation of a locks battery state."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Battery"
self._attr_native_value = self.coordinator.data.battery_level
class LockOperator(BaseLockEntity, RestoreEntity, SensorEntity):
"""Representation of a locks last operator."""
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Last Operator"
if self.coordinator.data.last_user:
self._attr_native_value = self.coordinator.data.last_user
elif not self._attr_native_value:
self._attr_native_value = "Unknown"
async def async_added_to_hass(self) -> None:
"""Restore on startup since we don't have event history."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if not last_state or last_state.state == STATE_UNAVAILABLE:
return
self._attr_native_value = last_state.state
class LockTrigger(BaseLockEntity, RestoreEntity, SensorEntity):
"""Representation of a locks state change reason."""
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Last Trigger"
if self.coordinator.data.last_reason:
self._attr_native_value = self.coordinator.data.last_reason
elif not self._attr_native_value:
self._attr_native_value = "Unknown"
async def async_added_to_hass(self) -> None:
"""Restore on startup since we don't have event history."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if not last_state or last_state.state == STATE_UNAVAILABLE:
return
self._attr_native_value = last_state.state
class SensorBattery(BaseLockEntity, SensorEntity):
"""Representation of sensor battery."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Sensor Battery"
self._attr_native_value = (
self.coordinator.data.sensor.battery
if self.coordinator.data.sensor
else None
)

View File

@@ -0,0 +1,297 @@
"""Services for ttlock integration."""
from datetime import time
import logging
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_ENABLED, WEEKDAYS
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.util.dt import as_utc
from .const import (
CONF_ALL_DAY,
CONF_AUTO_UNLOCK,
CONF_END_TIME,
CONF_SECONDS,
CONF_START_TIME,
CONF_WEEK_DAYS,
DOMAIN,
SVC_CLEANUP_PASSCODES,
SVC_CONFIG_AUTOLOCK,
SVC_CONFIG_PASSAGE_MODE,
SVC_CREATE_PASSCODE,
SVC_LIST_PASSCODES,
SVC_LIST_RECORDS,
)
from .coordinator import LockUpdateCoordinator, coordinator_for
from .models import AddPasscodeConfig, OnOff, PassageModeConfig
_LOGGER = logging.getLogger(__name__)
_LIST_RECORDS_SCHEMA = vol.Schema(
{
vol.Optional("start_date"): cv.datetime,
vol.Optional("end_date"): cv.datetime,
vol.Optional("page_size", default=50): vol.All(
vol.Coerce(int), vol.Range(min=1, max=200)
),
vol.Optional("page_no", default=1): vol.All(vol.Coerce(int), vol.Range(min=1)),
}
)
class Services:
"""Wraps service handlers."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the service singleton."""
self.hass = hass
def register(self) -> None:
"""Register services for ttlock integration."""
self.hass.services.register(
DOMAIN,
SVC_CONFIG_PASSAGE_MODE,
self.handle_configure_passage_mode,
vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(CONF_ENABLED): cv.boolean,
vol.Optional(CONF_AUTO_UNLOCK, default=False): cv.boolean,
vol.Optional(CONF_ALL_DAY, default=False): cv.boolean,
vol.Optional(CONF_START_TIME, default=time()): cv.time,
vol.Optional(CONF_END_TIME, default=time()): cv.time,
vol.Optional(CONF_WEEK_DAYS, default=WEEKDAYS): cv.weekdays,
}
),
)
self.hass.services.register(
DOMAIN,
SVC_CREATE_PASSCODE,
self.handle_create_passcode,
vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required("passcode_name"): cv.string,
vol.Required("passcode"): cv.string,
vol.Required("start_time", default=time()): cv.datetime,
vol.Required("end_time", default=time()): cv.datetime,
}
),
)
self.hass.services.register(
DOMAIN,
SVC_CLEANUP_PASSCODES,
self.handle_cleanup_passcodes,
schema=vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
}
),
supports_response=SupportsResponse.OPTIONAL,
)
self.hass.services.register(
DOMAIN,
SVC_LIST_PASSCODES,
self.handle_list_passcodes,
schema=vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
}
),
supports_response=SupportsResponse.ONLY,
)
self.hass.services.register(
DOMAIN,
SVC_LIST_RECORDS,
self.handle_list_records,
schema=vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional("start_date"): cv.datetime,
vol.Optional("end_date"): cv.datetime,
vol.Optional("page_size", default=50): vol.All(
vol.Coerce(int), vol.Range(min=1, max=200)
),
vol.Optional("page_no", default=1): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
}
),
supports_response=SupportsResponse.ONLY,
)
self.hass.services.register(
DOMAIN,
SVC_CONFIG_AUTOLOCK,
self.handle_configure_autolock,
vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(CONF_ENABLED): cv.boolean,
vol.Optional(CONF_SECONDS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=60)
),
}
),
)
def _get_coordinators(self, call: ServiceCall) -> dict[str, LockUpdateCoordinator]:
"""Get coordinators for the requested entities.
Returns a dictionary mapping entity_ids to their coordinators.
Filters out any entity_ids that don't have associated coordinators.
Args:
call: The service call containing entity_ids
Returns:
A dictionary where keys are entity_ids and values are their corresponding
LockUpdateCoordinators. Only includes entries where a coordinator exists.
"""
entity_ids = call.data.get(ATTR_ENTITY_ID)
if entity_ids:
return {
entity_id: coordinator
for entity_id in entity_ids
if (coordinator := coordinator_for(self.hass, entity_id))
}
return {}
async def handle_list_passcodes(self, call: ServiceCall) -> ServiceResponse:
"""List all passcodes for the selected locks."""
passcodes = {}
for entity_id, coordinator in self._get_coordinators(call).items():
codes = await coordinator.api.list_passcodes(coordinator.lock_id)
passcodes[entity_id] = [
{
"name": code.name,
"passcode": code.passcode,
"type": code.type.name,
"start_date": code.start_date,
"end_date": code.end_date,
"expired": code.expired,
}
for code in codes
]
return {"passcodes": passcodes}
async def handle_configure_passage_mode(self, call: ServiceCall):
"""Enable passage mode for the given entities."""
start_time = call.data.get(CONF_START_TIME)
end_time = call.data.get(CONF_END_TIME)
days = [WEEKDAYS.index(day) + 1 for day in call.data.get(CONF_WEEK_DAYS)]
config = PassageModeConfig(
passageMode=OnOff.on if call.data.get(CONF_ENABLED) else OnOff.off,
autoUnlock=OnOff.on if call.data.get(CONF_AUTO_UNLOCK) else OnOff.off,
isAllDay=OnOff.on if call.data.get(CONF_ALL_DAY) else OnOff.off,
startDate=start_time.hour * 60 + start_time.minute,
endDate=end_time.hour * 60 + end_time.minute,
weekDays=days,
)
for _entity_id, coordinator in self._get_coordinators(call).items():
if await coordinator.api.set_passage_mode(coordinator.lock_id, config):
coordinator.data.passage_mode_config = config
coordinator.async_update_listeners()
async def handle_create_passcode(self, call: ServiceCall):
"""Create a new passcode for the given entities."""
start_time_val = call.data.get("start_time")
start_time_utc = as_utc(start_time_val)
start_time = int(start_time_utc.timestamp() * 1000)
end_time_val = call.data.get("end_time")
end_time_utc = as_utc(end_time_val)
end_time = int(end_time_utc.timestamp() * 1000)
config = AddPasscodeConfig(
passcode=call.data.get("passcode"),
passcodeName=call.data.get("passcode_name"),
startDate=start_time,
endDate=end_time,
)
for _entity_id, coordinator in self._get_coordinators(call).items():
await coordinator.api.add_passcode(coordinator.lock_id, config)
async def handle_cleanup_passcodes(self, call: ServiceCall) -> ServiceResponse:
"""Clean up expired passcodes for the given entities."""
removed = {}
for entity_id, coordinator in self._get_coordinators(call).items():
removed_for_lock = []
codes = await coordinator.api.list_passcodes(coordinator.lock_id)
for code in codes:
if code.expired:
if await coordinator.api.delete_passcode(
coordinator.lock_id, code.id
):
removed_for_lock.append(code.name)
if removed_for_lock:
removed[entity_id] = removed_for_lock
return {"removed": removed}
async def handle_configure_autolock(self, call: ServiceCall):
"""Set the autolock seconds."""
if call.data.get(CONF_ENABLED):
seconds = call.data.get(CONF_SECONDS) or 10
else:
seconds = 0
for coordinator in self._get_coordinators(call).values():
if await coordinator.api.set_auto_lock(coordinator.lock_id, seconds):
coordinator.data.auto_lock_seconds = seconds
coordinator.async_update_listeners()
async def handle_list_records(self, call: ServiceCall) -> ServiceResponse:
"""List records for the selected locks."""
records = {}
params = {}
# Convert datetime parameters to millisecond timestamps if provided
if start_date := call.data.get("start_date"):
params["start_date"] = int(as_utc(start_date).timestamp() * 1000)
if end_date := call.data.get("end_date"):
params["end_date"] = int(as_utc(end_date).timestamp() * 1000)
# Get pagination values from call data with defaults
params["page_no"] = call.data.get("page_no", 1)
params["page_size"] = min(call.data.get("page_size", 50), 200)
for entity_id, coordinator in self._get_coordinators(call).items():
lock_records = await coordinator.api.get_lock_records(
coordinator.lock_id, **params
)
records[entity_id] = [
{
"id": record.id,
"lock_id": record.lock_id,
"record_type": record.record_type.name,
"success": record.success,
"username": record.username,
"keyboard_pwd": record.keyboard_pwd,
"lock_date": record.lock_date,
"server_date": record.server_date,
}
for record in lock_records
]
return {"records": records}

View File

@@ -0,0 +1,190 @@
configure_passage_mode:
name: Configure passage mode
description: Tries to configure passage mode for a lock (or set of locks)
target:
entity:
integration: ttlock
domain: lock
fields:
enabled:
name: Enabled
description: Should passage mode be active (if false, no other fields are required)
required: true
default: true
selector:
boolean:
auto_unlock:
name: Auto-unlock
description: Should the the lock auto unlock when passage mode starts
required: false
default: false
selector:
boolean:
all_day:
name: All day
description: If set, the enabled setting applies 24/7. If not set then start_time, end_time and week_days is required.
required: false
default: false
selector:
boolean:
start_time:
name: Start time
description: When passage mode should begin (only hour + minute, seconds are ignored)
required: false
default: "00:00"
selector:
time:
end_time:
name: End time
description: When passage mode should end (only hour + minute, seconds are ignored)
required: false
default: "00:00"
selector:
time:
days:
name: Week days
description: Which days should the passage mode schedule apply to?
required: false
default:
- mon
- tue
- wed
- thu
- fri
- sat
- sun
selector:
select:
options:
- label: Monday
value: mon
- label: Tuesday
value: tue
- label: Wednesday
value: wed
- label: Thursday
value: thu
- label: Friday
value: fri
- label: Saturday
value: sat
- label: Sunday
value: sun
multiple: true
mode: list
create_passcode:
name: Create a new pass code
description: Tries to create a new (temporary) passcode for a lock.
target:
entity:
integration: ttlock
domain: lock
fields:
passcode_name:
name: Pass code name
description: The unique name of this pass code (Can be whatever you like)
required: true
default: My passcode name
selector:
text:
passcode:
name: Passcode
description: The passcode that will be typed by the user to unlock the lock. (4-9 digits)
required: true
default: ""
selector:
text:
type: number
start_time:
name: Start date / time
description: What date/time pass code will become valid
required: true
selector:
datetime:
end_time:
name: End date / time
description: What date/time pass code will become invalid
required: true
selector:
datetime:
cleanup_passcodes:
name: Remove expired passcodes
description: Lists all passcodes for the selected lock and deletes ALL expired passcodes (where the end of validity date is older is past).
target:
entity:
integration: ttlock
domain: lock
list_passcodes:
name: List passcodes
description: Lists all passcodes for the selected lock, including their names, codes, and validity periods.
target:
entity:
integration: ttlock
domain: lock
list_records:
name: List lock records
description: Lists operation records for the selected lock.
target:
entity:
integration: ttlock
domain: lock
fields:
start_date:
name: Start date
description: Start date for record search (optional)
required: false
selector:
datetime:
end_date:
name: End date
description: End date for record search (optional)
required: false
selector:
datetime:
page_size:
name: Page size
description: Number of records to return (default 100, max 200)
required: false
default: 100
selector:
number:
min: 1
max: 200
page_no:
name: Page number
description: Page number to return (default 1)
required: false
default: 1
selector:
number:
min: 1
max: 10
configure_autolock:
name: Configure Autolock
description: Configure Autolock of the device.
target:
entity:
integration: ttlock
domain: lock
fields:
enabled:
name: Enabled
description: Should the Autolock feature for the lock be enabled?
required: true
default: false
selector:
boolean:
seconds:
name: Seconds
description: How many seconds before the lock should Autolock? (0-60 secs | default 10)
required: false
selector:
number:
min: 0
max: 60
unit_of_measurement: secs

View File

@@ -0,0 +1,21 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View File

@@ -0,0 +1,77 @@
"""Switch setup for our Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import lock_coordinators
from .entity import BaseLockEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all the locks for the config entry."""
async_add_entities(
[
entity
for coordinator in lock_coordinators(hass, entry)
for entity in (
AutoLock(coordinator),
LockSound(coordinator),
)
]
)
class AutoLock(BaseLockEntity, SwitchEntity):
"""The entity object for a switch."""
_attr_device_class = SwitchDeviceClass.SWITCH
@property
def extra_state_attributes(self):
"""Define any extra state sttr."""
attributes = {}
attributes["seconds"] = self.coordinator.data.auto_lock_seconds
return attributes
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Auto Lock"
self._attr_is_on = self.coordinator.data.auto_lock_seconds > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.set_auto_lock(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.set_auto_lock(False)
class LockSound(BaseLockEntity, SwitchEntity):
"""The entity object for a switch."""
_attr_device_class = SwitchDeviceClass.SWITCH
def _update_from_coordinator(self) -> None:
"""Fetch state from the device."""
self._attr_name = f"{self.coordinator.data.name} Lock Sound"
self._attr_is_on = self.coordinator.data.lock_sound
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.set_lock_sound(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.set_lock_sound(False)

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"already_in_progress": "Configuration flow is already in progress",
"authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth_error": "Received invalid token data.",
"user_rejected_authorize": "Account linking rejected: {error}"
},
"create_entry": {
"default": "Successfully authenticated"
},
"step": {
"auth": {
"title": "Login to TTLock",
"description": "Please provide the login details that you used to log into the TTLock app when setting up your locks",
"data": {
"password": "Password",
"username": "Username"
}
},
"pick_implementation": {
"title": "Pick Authentication Method"
}
}
},
"issues": {
"no_webhook_url": {
"description": "An error ocurred while trying to generate a webhook url. Please make sure that you have a URL configured in Settings > System > Network.",
"title": "Unable to generate webhook URL."
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"abort": {
"already_configured": "A conta já está configurada",
"already_in_progress": "A configuração já está em andamento",
"authorize_url_timeout": "Tempo limite gerando URL de autorização.",
"missing_configuration": "O componente não está configurado. Siga a documentação.",
"no_url_available": "Nenhuma URL disponível. Para obter informações sobre este erro, [verifique a seção de ajuda]({docs_url})",
"oauth_error": "Dados de token inválidos recebidos.",
"user_rejected_authorize": "Vinculação de conta rejeitada: {error}"
},
"create_entry": {
"default": "Autenticado com sucesso"
},
"step": {
"auth": {
"title": "Login no TTLock",
"description": "Forneça os detalhes de login que você usou para fazer login no aplicativo TTLock ao configurar seus bloqueios",
"data": {
"password": "Senha",
"username": "Nome de usuário"
}
},
"pick_implementation": {
"title": "Escolha o método de autenticação"
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"abort": {
"already_configured": "A conta já está configurada",
"already_in_progress": "A configuração já está em curso",
"authorize_url_timeout": "Tempo limite do URL de autorização.",
"missing_configuration": "O componente não está configurado. Ver a documentação.",
"no_url_available": "Nenhum URL disponível. Para obter informações sobre este erro, [verifique a seção de ajuda]({docs_url})",
"oauth_error": "Dados de token inválidos recebidos.",
"user_rejected_authorize": "Vinculação de conta rejeitada: {error}"
},
"create_entry": {
"default": "Autenticado com sucesso"
},
"step": {
"auth": {
"title": "Login no TTLock",
"description": "Forneça os detalhes de login que usa para fazer login no aplicativo TTLock ao configurar seus bloqueios",
"data": {
"password": "Senha",
"username": "Nome de utilizador"
}
},
"pick_implementation": {
"title": "Escolha o método de autenticação"
}
}
}
}

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "Účet je už nakonfigurovaný",
"already_in_progress": "Tok konfigurácie už prebieha",
"authorize_url_timeout": "Časový limit generovania autorizačnej adresy URL.",
"missing_configuration": "Komponent nie je nakonfigurovaný. Postupujte podľa dokumentácie.",
"no_url_available": "Nie je k dispozícii žiadna adresa URL. Informácie o tejto chybe nájdete [pozrite si sekciu pomocníka]({docs_url})",
"oauth_error": "Prijaté neplatné údaje tokenu.",
"user_rejected_authorize": "Prepojenie účtu bolo odmietnuté: {error}"
},
"create_entry": {
"default": "Úspešne overené"
},
"step": {
"auth": {
"title": "Prihlásiť sa do TTLock",
"description": "Pri nastavovaní zámkov uveďte prihlasovacie údaje, ktoré ste použili na prihlásenie do aplikácie TTLock",
"data": {
"password": "Heslo",
"username": "Používateľské meno"
}
},
"pick_implementation": {
"title": "Vyberte metódu overenia"
}
}
},
"issues": {
"no_webhook_url": {
"description": "Pri pokuse o vygenerovanie webovej adresy webhooku sa vyskytla chyba. Uistite sa, že máte nakonfigurovanú adresu URLSettings > System > Network.",
"title": "Nie je možné vygenerovať webovú adresu webhooku."
}
}
}