398 lines
13 KiB
Python
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)
|