Skip to content

Commit

Permalink
Refactor devices into subpackages and deprecate old names (#716)
Browse files Browse the repository at this point in the history
* Refactor devices into subpackages and deprecate old names

* Tweak and add tests

* Fix linting

* Remove duplicate implementations affecting project coverage

* Update post review

* Add device base class attributes and rename subclasses

* Rename Module to BaseModule

* Remove has_emeter_history

* Fix missing _time in init

* Update post review

* Fix test_readmeexamples

* Fix erroneously duped files

* Clean up iot and smart imports

* Update post latest review

* Tweak Device docstring
  • Loading branch information
sdb9696 committed Feb 4, 2024
1 parent 6afd05b commit 0d119e6
Show file tree
Hide file tree
Showing 49 changed files with 1,047 additions and 607 deletions.
9 changes: 6 additions & 3 deletions devtools/create_module_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
import asyncio
import json
from pathlib import Path
from typing import cast

import typer

from kasa import Discover, SmartDevice
from kasa import Discover
from kasa.iot import IotDevice

app = typer.Typer()


def create_fixtures(dev: SmartDevice, outputdir: Path):
def create_fixtures(dev: IotDevice, outputdir: Path):
"""Iterate over supported modules and create version-specific fixture files."""
for name, module in dev.modules.items():
module_dir = outputdir / name
Expand Down Expand Up @@ -43,13 +45,14 @@ def create_module_fixtures(
"""Create module fixtures for given host/network."""
devs = []
if host is not None:
dev: SmartDevice = asyncio.run(Discover.discover_single(host))
dev: IotDevice = cast(IotDevice, asyncio.run(Discover.discover_single(host)))
devs.append(dev)
else:
if network is None:
network = "255.255.255.255"
devs = asyncio.run(Discover.discover(target=network)).values()
for dev in devs:
dev = cast(IotDevice, dev)
asyncio.run(dev.update())

for dev in devs:
Expand Down
10 changes: 5 additions & 5 deletions devtools/dump_devinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@
from kasa import (
AuthenticationException,
Credentials,
Device,
Discover,
SmartDevice,
SmartDeviceException,
TimeoutException,
)
from kasa.discover import DiscoveryResult
from kasa.exceptions import SmartErrorCode
from kasa.tapo.tapodevice import TapoDevice
from kasa.smart import SmartDevice

Call = namedtuple("Call", "module method")
SmartCall = namedtuple("SmartCall", "module request should_succeed")
Expand Down Expand Up @@ -119,9 +119,9 @@ def default_to_regular(d):
return d


async def handle_device(basedir, autosave, device: SmartDevice, batch_size: int):
async def handle_device(basedir, autosave, device: Device, batch_size: int):
"""Create a fixture for a single device instance."""
if isinstance(device, TapoDevice):
if isinstance(device, SmartDevice):
filename, copy_folder, final = await get_smart_fixture(device, batch_size)
else:
filename, copy_folder, final = await get_legacy_fixture(device)
Expand Down Expand Up @@ -319,7 +319,7 @@ async def _make_requests_or_exit(
exit(1)


async def get_smart_fixture(device: TapoDevice, batch_size: int):
async def get_smart_fixture(device: SmartDevice, batch_size: int):
"""Get fixture for new TAPO style protocol."""
extra_test_calls = [
SmartCall(
Expand Down
67 changes: 54 additions & 13 deletions kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
to be handled by the user of the library.
"""
from importlib.metadata import version
from typing import TYPE_CHECKING
from warnings import warn

from kasa.bulb import Bulb
from kasa.credentials import Credentials
from kasa.device import Device
from kasa.device_type import DeviceType
from kasa.deviceconfig import (
ConnectionType,
DeviceConfig,
Expand All @@ -29,18 +33,14 @@
TimeoutException,
UnsupportedDeviceException,
)
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import (
IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401
)
from kasa.plug import Plug
from kasa.protocol import BaseProtocol
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.smartdevice import DeviceType, SmartDevice
from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug
from kasa.smartprotocol import SmartProtocol
from kasa.smartstrip import SmartStrip

__version__ = version("python-kasa")

Expand All @@ -50,18 +50,15 @@
"BaseProtocol",
"IotProtocol",
"SmartProtocol",
"SmartBulb",
"SmartBulbPreset",
"BulbPreset",
"TurnOnBehaviors",
"TurnOnBehavior",
"DeviceType",
"EmeterStatus",
"SmartDevice",
"Device",
"Bulb",
"Plug",
"SmartDeviceException",
"SmartPlug",
"SmartStrip",
"SmartDimmer",
"SmartLightStrip",
"AuthenticationException",
"UnsupportedDeviceException",
"TimeoutException",
Expand All @@ -72,11 +69,55 @@
"DeviceFamilyType",
]

from . import iot

deprecated_names = ["TPLinkSmartHomeProtocol"]
deprecated_smart_devices = {
"SmartDevice": iot.IotDevice,
"SmartPlug": iot.IotPlug,
"SmartBulb": iot.IotBulb,
"SmartLightStrip": iot.IotLightStrip,
"SmartStrip": iot.IotStrip,
"SmartDimmer": iot.IotDimmer,
"SmartBulbPreset": BulbPreset,
}


def __getattr__(name):
if name in deprecated_names:
warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1)
return globals()[f"_deprecated_{name}"]
if name in deprecated_smart_devices:
new_class = deprecated_smart_devices[name]
package_name = ".".join(new_class.__module__.split(".")[:-1])
warn(
f"{name} is deprecated, use {new_class.__name__} "
+ f"from package {package_name} instead or use Discover.discover_single()"
+ " and Device.connect() to support new protocols",
DeprecationWarning,
stacklevel=1,
)
return new_class
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


if TYPE_CHECKING:
SmartDevice = Device
SmartBulb = iot.IotBulb
SmartPlug = iot.IotPlug
SmartLightStrip = iot.IotLightStrip
SmartStrip = iot.IotStrip
SmartDimmer = iot.IotDimmer
SmartBulbPreset = BulbPreset
# Instanstiate all classes so the type checkers catch abstract issues
from . import smart

smart.SmartDevice("127.0.0.1")
smart.SmartPlug("127.0.0.1")
smart.SmartBulb("127.0.0.1")
iot.IotDevice("127.0.0.1")
iot.IotPlug("127.0.0.1")
iot.IotBulb("127.0.0.1")
iot.IotLightStrip("127.0.0.1")
iot.IotStrip("127.0.0.1")
iot.IotDimmer("127.0.0.1")
144 changes: 144 additions & 0 deletions kasa/bulb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Module for Device base class."""
from abc import ABC, abstractmethod
from typing import Dict, List, NamedTuple, Optional

from .device import Device

try:
from pydantic.v1 import BaseModel
except ImportError:
from pydantic import BaseModel


class ColorTempRange(NamedTuple):
"""Color temperature range."""

min: int
max: int


class HSV(NamedTuple):
"""Hue-saturation-value."""

hue: int
saturation: int
value: int


class BulbPreset(BaseModel):
"""Bulb configuration preset."""

index: int
brightness: int

# These are not available for effect mode presets on light strips
hue: Optional[int]
saturation: Optional[int]
color_temp: Optional[int]

# Variables for effect mode presets
custom: Optional[int]
id: Optional[str]
mode: Optional[int]


class Bulb(Device, ABC):
"""Base class for TP-Link Bulb."""

def _raise_for_invalid_brightness(self, value):
if not isinstance(value, int) or not (0 <= value <= 100):
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")

@property
@abstractmethod
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""

@property
@abstractmethod
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""

@property
@abstractmethod
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""

@property
@abstractmethod
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""

@property
@abstractmethod
def has_effects(self) -> bool:
"""Return True if the device supports effects."""

@property
@abstractmethod
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""

@property
@abstractmethod
def color_temp(self) -> int:
"""Whether the bulb supports color temperature changes."""

@property
@abstractmethod
def brightness(self) -> int:
"""Return the current brightness in percentage."""

@abstractmethod
async def set_hsv(
self,
hue: int,
saturation: int,
value: Optional[int] = None,
*,
transition: Optional[int] = None,
) -> Dict:
"""Set new HSV.
Note, transition is not supported and will be ignored.
:param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100]
:param int value: value in percentage [0, 100]
:param int transition: transition in milliseconds.
"""

@abstractmethod
async def set_color_temp(
self, temp: int, *, brightness=None, transition: Optional[int] = None
) -> Dict:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""

@abstractmethod
async def set_brightness(
self, brightness: int, *, transition: Optional[int] = None
) -> Dict:
"""Set the brightness in percentage.
Note, transition is not supported and will be ignored.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""

@property
@abstractmethod
def presets(self) -> List[BulbPreset]:
"""Return a list of available bulb setting presets."""

0 comments on commit 0d119e6

Please sign in to comment.