Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ The following devices have been tested and confirmed as working. If your device
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70
- **Doorbells and chimes**: D100C, D130, D230
- **Locks**: DL110
- **Vacuums**: RV20 Max Plus, RV30 Max
- **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
Expand Down
5 changes: 5 additions & 0 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- **D230**
- Hardware: 1.20 (EU) / Firmware: 1.1.19

### Locks

- **DL110**
- Hardware: 2.0 (US) / Firmware: 1.2.5

### Vacuums

- **RV20 Max Plus**
Expand Down
1 change: 1 addition & 0 deletions devtools/generate_supported.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class SupportedVersion(NamedTuple):
DeviceType.Camera: "Cameras",
DeviceType.Doorbell: "Doorbells and chimes",
DeviceType.Chime: "Doorbells and chimes",
DeviceType.DoorLock: "Locks",
DeviceType.Vacuum: "Vacuums",
DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Hub-Connected Devices",
Expand Down
106 changes: 106 additions & 0 deletions kasa/cli/lock.py
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
15 changes: 10 additions & 5 deletions kasa/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"lightstrip",
"smart",
"camera",
"smartcam",
]

ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
Expand Down Expand Up @@ -72,6 +73,7 @@ def _legacy_type_to_class(_type: str) -> Any:
"device": None,
"feature": None,
"light": None,
"lock": None,
"wifi": None,
"time": None,
"schedule": None,
Expand All @@ -93,6 +95,9 @@ def _legacy_type_to_class(_type: str) -> Any:
"hsv": "light",
"temperature": "light",
"effect": "light",
# lock commands runnnable at top level
"unlock": "lock",
"history": "lock",
Comment on lines +98 to +100
Copy link
Member

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

"vacuum": "vacuum",
"hub": "hub",
},
Expand Down Expand Up @@ -259,7 +264,7 @@ async def cli(
"level": logging.DEBUG if debug > 0 else logging.INFO
}
try:
from rich.logging import RichHandler
from rich.logging import RichHandler # type: ignore[import]

rich_config = {
"show_time": False,
Expand Down Expand Up @@ -303,13 +308,13 @@ async def cli(
device_updated = False
device_discovered = False

if type is not None and type not in {"smart", "camera"}:
if type is not None and type not in {"smart", "camera", "smartcam"}:
from kasa.deviceconfig import DeviceConfig

config = DeviceConfig(host=host, port_override=port, timeout=timeout)
dev = _legacy_type_to_class(type)(host, config=config)
elif type in {"smart", "camera"} or (device_family and encrypt_type):
if type == "camera":
elif type in {"smart", "camera", "smartcam"} or (device_family and encrypt_type):
if type == "camera" or type == "smartcam":
encrypt_type = "AES"
https = True
login_version = 2
Expand Down Expand Up @@ -392,7 +397,7 @@ async def async_wrapped_device(device: Device):
async def shell(dev: Device) -> None:
"""Open interactive shell."""
echo(f"Opening shell for {dev}")
from ptpython.repl import embed
from ptpython.repl import embed # type: ignore[import]

logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing
logging.getLogger("asyncio").setLevel(logging.WARNING)
Expand Down
3 changes: 3 additions & 0 deletions kasa/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions kasa/device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class DeviceType(Enum):
Vacuum = "vacuum"
Chime = "chime"
Doorbell = "doorbell"
DoorLock = "doorlock"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking whether this should be Doorlock to keep the consistency with the doorbell above, but would Lock be enough? It is what you also use in the cli, and it is descriptive enough, IMO.

Unknown = "unknown"

@staticmethod
Expand Down
1 change: 1 addition & 0 deletions kasa/deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class DeviceFamily(Enum):
SmartTapoRobovac = "SMART.TAPOROBOVAC"
SmartTapoChime = "SMART.TAPOCHIME"
SmartTapoDoorbell = "SMART.TAPODOORBELL"
SmartTapoLock = "SMART.TAPOLOCK"


class _DeviceConfigBaseMixin(DataClassJSONMixin):
Expand Down
4 changes: 4 additions & 0 deletions kasa/interfaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,6 +21,9 @@
"Light",
"LightEffect",
"LightState",
"Lock",
"LockEvent",
"LockMethod",
Comment on lines +24 to +26
Copy link
Member

Choose a reason for hiding this comment

The 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",
Expand Down
70 changes: 70 additions & 0 deletions kasa/interfaces/lock.py
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."""
4 changes: 4 additions & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ class Module(ABC):
# SMARTCAM only modules
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
SmartCamLock: Final[ModuleName[smartcam.Lock]] = ModuleName("Lock")
SmartCamLockHistory: Final[ModuleName[smartcam.LockHistory]] = ModuleName(
"LockHistory"
)

# Vacuum modules
Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean")
Expand Down
4 changes: 4 additions & 0 deletions kasa/smartcam/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from .led import Led
from .lensmask import LensMask
from .linecrossingdetection import LineCrossingDetection
from .lock import Lock
from .lockhistory import LockHistory
from .matter import Matter
from .meowdetection import MeowDetection
from .motiondetection import MotionDetection
Expand All @@ -35,6 +37,8 @@
"GlassDetection",
"Led",
"LineCrossingDetection",
"Lock",
"LockHistory",
"MeowDetection",
"PanTilt",
"PersonDetection",
Expand Down
Loading
Loading