Files
homeassistant/custom_components/ttlock/models.py
Victor Alexandrovich Tsyrenschikov 373ed28445 python
2026-03-30 20:25:42 +05:00

398 lines
13 KiB
Python

"""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)