python
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user