-
-
Notifications
You must be signed in to change notification settings - Fork 247
Add support for Tapo lock device DL110 and implement lock control features #1646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """Module for cli lock control commands.""" | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| import asyncclick as click | ||
|
|
||
| from kasa import Device | ||
|
|
||
| from .common import echo, error, pass_dev_or_child | ||
|
|
||
| if TYPE_CHECKING: | ||
| from kasa.smartcam.modules import Lock, LockHistory | ||
|
|
||
|
|
||
| @click.group() | ||
| @pass_dev_or_child | ||
| def lock(dev: Device) -> None: | ||
| """Commands to control lock settings.""" | ||
|
|
||
|
|
||
| @lock.command() | ||
| @pass_dev_or_child | ||
| async def state(dev: Device) -> None: | ||
| """Get lock state.""" | ||
| mod = dev.modules.get("Lock") | ||
| if not mod: | ||
| error("This device does not support lock control.") | ||
| return | ||
|
|
||
| lock_module: Lock = mod # type: ignore[assignment] | ||
| echo(f"Lock state: {'locked' if lock_module.is_locked else 'unlocked'}") # type: ignore[attr-defined] | ||
| echo(f"Battery level: {lock_module.battery_level}%") # type: ignore[attr-defined] | ||
| if lock_module.auto_lock_enabled: # type: ignore[attr-defined] | ||
| echo(f"Auto-lock enabled: {lock_module.auto_lock_time}s") # type: ignore[attr-defined] | ||
| else: | ||
| echo("Auto-lock enabled: false") | ||
|
|
||
| if last_user := lock_module.last_unlock_user: # type: ignore[attr-defined] | ||
| echo(f"Last unlocked by: {last_user} ({lock_module.last_unlock_method.value})") # type: ignore[attr-defined] | ||
|
|
||
|
|
||
| @lock.command() | ||
| @pass_dev_or_child | ||
| async def unlock(dev: Device) -> None: | ||
| """Unlock the device.""" | ||
| mod = dev.modules.get("Lock") | ||
| if not mod: | ||
| error("This device does not support lock control.") | ||
| return | ||
|
|
||
| lock_module: Lock = mod # type: ignore[assignment] | ||
| echo("Unlocking...") | ||
| await lock_module.unlock() # type: ignore[attr-defined] | ||
| await dev.update() | ||
| echo(f"Lock state: {'locked' if lock_module.is_locked else 'unlocked'}") # type: ignore[attr-defined] | ||
|
|
||
|
|
||
| @lock.command(name="lock") | ||
| @pass_dev_or_child | ||
| async def lock_device(dev: Device) -> None: | ||
| """Lock the device.""" | ||
| mod = dev.modules.get("Lock") | ||
| if not mod: | ||
| error("This device does not support lock control.") | ||
| return | ||
|
|
||
| lock_module: Lock = mod # type: ignore[assignment] | ||
| echo("Locking...") | ||
| await lock_module.lock() # type: ignore[attr-defined] | ||
| await dev.update() | ||
| echo(f"Lock state: {'locked' if lock_module.is_locked else 'unlocked'}") # type: ignore[attr-defined] | ||
|
|
||
|
|
||
| @lock.command() | ||
| @pass_dev_or_child | ||
| async def history(dev: Device) -> None: | ||
| """Get lock history.""" | ||
| mod = dev.modules.get("LockHistory") | ||
| if not mod: | ||
| error("This device does not support lock history.") | ||
| return | ||
|
|
||
| history_module: LockHistory = mod # type: ignore[assignment] | ||
| echo(f"Total lock events: {history_module.total_log_count}\n") # type: ignore[attr-defined] | ||
|
|
||
| if not history_module.logs: # type: ignore[attr-defined] | ||
| echo("No lock events recorded.") | ||
| return | ||
|
|
||
| echo("Recent lock events:") | ||
| for i, entry in enumerate(history_module.logs[:10], 1): # type: ignore[attr-defined] | ||
| user_info = f" by {entry.user}" if entry.user else "" | ||
| echo( | ||
| f" {i}. {entry.timestamp.strftime('%Y-%m-%d %H:%M:%S')} - " | ||
| f"{entry.event_type.value.title()}{user_info} ({entry.method.value})" | ||
| ) | ||
|
|
||
| if history_module.last_lock: # type: ignore[attr-defined] | ||
| timestamp = history_module.last_lock.timestamp.strftime("%Y-%m-%d %H:%M:%S") # type: ignore[attr-defined] | ||
| echo(f"\nLast locked: {timestamp}") | ||
|
|
||
| if history_module.last_unlock: # type: ignore[attr-defined] | ||
| timestamp = history_module.last_unlock.timestamp.strftime("%Y-%m-%d %H:%M:%S") # type: ignore[attr-defined] | ||
| echo(f"Last unlocked: {timestamp}") | ||
|
|
||
| return history_module.logs # type: ignore |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,6 +160,8 @@ def get_device_class_from_family( | |
| "SMART.KASASWITCH": SmartDevice, | ||
| "SMART.IPCAMERA.HTTPS": SmartCamDevice, | ||
| "SMART.TAPODOORBELL.HTTPS": SmartCamDevice, | ||
| "SMART.TAPOLOCK": SmartCamDevice, | ||
| "SMART.TAPOLOCK.HTTPS": SmartCamDevice, | ||
|
Comment on lines
+163
to
+164
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this always HTTPS as the test implies? |
||
| "SMART.TAPOROBOVAC.HTTPS": SmartDevice, | ||
| "IOT.SMARTPLUGSWITCH": IotPlug, | ||
| "IOT.SMARTBULB": IotBulb, | ||
|
|
@@ -199,6 +201,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | |
| if ctype.device_family in { | ||
| DeviceFamily.SmartIpCamera, | ||
| DeviceFamily.SmartTapoDoorbell, | ||
| DeviceFamily.SmartTapoLock, | ||
| }: | ||
| if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: | ||
| return None | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ class DeviceType(Enum): | |
| Vacuum = "vacuum" | ||
| Chime = "chime" | ||
| Doorbell = "doorbell" | ||
| DoorLock = "doorlock" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking whether this should be |
||
| Unknown = "unknown" | ||
|
|
||
| @staticmethod | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| from .light import Light, LightState | ||
| from .lighteffect import LightEffect | ||
| from .lightpreset import LightPreset | ||
| from .lock import Lock, LockEvent, LockMethod | ||
| from .thermostat import Thermostat, ThermostatState | ||
| from .time import Time | ||
|
|
||
|
|
@@ -20,6 +21,9 @@ | |
| "Light", | ||
| "LightEffect", | ||
| "LightState", | ||
| "Lock", | ||
| "LockEvent", | ||
| "LockMethod", | ||
|
Comment on lines
+24
to
+26
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interfaces are used to provide common interface for different device families, but as there are currently no locks for other types (iot, smart), let's avoid abstracting things and adding these interfaces until we have a need for the level of abstraction. |
||
| "LightPreset", | ||
| "Thermostat", | ||
| "ThermostatState", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| """Module for lock interface.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from enum import Enum | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| if TYPE_CHECKING: | ||
| pass | ||
|
|
||
|
|
||
| class LockEvent(Enum): | ||
| """Lock event type.""" | ||
|
|
||
| Lock = "lock" | ||
| Unlock = "unlock" | ||
| Unknown = "unknown" | ||
|
|
||
|
|
||
| class LockMethod(Enum): | ||
| """Method used to lock/unlock.""" | ||
|
|
||
| App = "app" | ||
| Manual = "manual" | ||
| Keypad = "keypad" | ||
| NFC = "nfc" | ||
| Fingerprint = "fingerprint" | ||
| HomeKit = "homekit" | ||
| Unknown = "unknown" | ||
|
|
||
| @classmethod | ||
| def from_value(cls, value: str) -> LockMethod: | ||
| """Return lock method from string value.""" | ||
| try: | ||
| return cls(value.lower()) | ||
| except ValueError: | ||
| return cls.Unknown | ||
|
|
||
|
|
||
| class Lock(ABC): | ||
| """Interface for lock devices.""" | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def is_locked(self) -> bool: | ||
| """Return True if the device is locked.""" | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def battery_level(self) -> int | None: | ||
| """Return battery level percentage or None if not available.""" | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def auto_lock_enabled(self) -> bool: | ||
| """Return True if auto-lock is enabled.""" | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def auto_lock_time(self) -> int | None: | ||
| """Return auto-lock time in seconds or None if not available.""" | ||
|
|
||
| @abstractmethod | ||
| async def lock(self) -> None: | ||
| """Lock the device.""" | ||
|
|
||
| @abstractmethod | ||
| async def unlock(self) -> None: | ||
| """Unlock the device.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's avoid polluting the main command namespace given how specific these are to the locks (albeit history could be something similar to trigger logs on some devices, but that's for another discussion).