python
This commit is contained in:
220
custom_components/ttlock/__init__.py
Normal file
220
custom_components/ttlock/__init__.py
Normal 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)
|
||||
345
custom_components/ttlock/api.py
Normal file
345
custom_components/ttlock/api.py
Normal 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"]]
|
||||
26
custom_components/ttlock/application_credentials.py
Normal file
26
custom_components/ttlock/application_credentials.py
Normal 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,
|
||||
),
|
||||
)
|
||||
63
custom_components/ttlock/binary_sensor.py
Normal file
63
custom_components/ttlock/binary_sensor.py
Normal 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()
|
||||
51
custom_components/ttlock/config_flow.py
Normal file
51
custom_components/ttlock/config_flow.py
Normal 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,
|
||||
)
|
||||
26
custom_components/ttlock/const.py
Normal file
26
custom_components/ttlock/const.py
Normal 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"
|
||||
324
custom_components/ttlock/coordinator.py
Normal file
324
custom_components/ttlock/coordinator.py
Normal 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()
|
||||
40
custom_components/ttlock/diagnostics.py
Normal file
40
custom_components/ttlock/diagnostics.py
Normal 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
|
||||
39
custom_components/ttlock/entity.py
Normal file
39
custom_components/ttlock/entity.py
Normal 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()
|
||||
48
custom_components/ttlock/lock.py
Normal file
48
custom_components/ttlock/lock.py
Normal 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()
|
||||
20
custom_components/ttlock/manifest.json
Normal file
20
custom_components/ttlock/manifest.json
Normal 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": []
|
||||
}
|
||||
397
custom_components/ttlock/models.py
Normal file
397
custom_components/ttlock/models.py
Normal 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)
|
||||
112
custom_components/ttlock/sensor.py
Normal file
112
custom_components/ttlock/sensor.py
Normal 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
|
||||
)
|
||||
297
custom_components/ttlock/services.py
Normal file
297
custom_components/ttlock/services.py
Normal 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}
|
||||
190
custom_components/ttlock/services.yaml
Normal file
190
custom_components/ttlock/services.yaml
Normal 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
|
||||
21
custom_components/ttlock/strings.json
Normal file
21
custom_components/ttlock/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
77
custom_components/ttlock/switch.py
Normal file
77
custom_components/ttlock/switch.py
Normal 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)
|
||||
35
custom_components/ttlock/translations/en.json
Normal file
35
custom_components/ttlock/translations/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
29
custom_components/ttlock/translations/pt-BR.json
Normal file
29
custom_components/ttlock/translations/pt-BR.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
custom_components/ttlock/translations/pt.json
Normal file
29
custom_components/ttlock/translations/pt.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
custom_components/ttlock/translations/sk.json
Normal file
35
custom_components/ttlock/translations/sk.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user