Refactor devices into subpackages and deprecate old names (#716)

* 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
This commit is contained in:
Steven B 2024-02-04 15:20:08 +00:00 committed by GitHub
parent 6afd05be59
commit 0d119e63d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1046 additions and 606 deletions

View File

@ -6,15 +6,17 @@ This script can be used to create fixture files for individual modules.
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import cast
import typer import typer
from kasa import Discover, SmartDevice from kasa import Discover
from kasa.iot import IotDevice
app = typer.Typer() 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.""" """Iterate over supported modules and create version-specific fixture files."""
for name, module in dev.modules.items(): for name, module in dev.modules.items():
module_dir = outputdir / name module_dir = outputdir / name
@ -43,13 +45,14 @@ def create_module_fixtures(
"""Create module fixtures for given host/network.""" """Create module fixtures for given host/network."""
devs = [] devs = []
if host is not None: 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) devs.append(dev)
else: else:
if network is None: if network is None:
network = "255.255.255.255" network = "255.255.255.255"
devs = asyncio.run(Discover.discover(target=network)).values() devs = asyncio.run(Discover.discover(target=network)).values()
for dev in devs: for dev in devs:
dev = cast(IotDevice, dev)
asyncio.run(dev.update()) asyncio.run(dev.update())
for dev in devs: for dev in devs:

View File

@ -23,14 +23,14 @@ from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest
from kasa import ( from kasa import (
AuthenticationException, AuthenticationException,
Credentials, Credentials,
Device,
Discover, Discover,
SmartDevice,
SmartDeviceException, SmartDeviceException,
TimeoutException, TimeoutException,
) )
from kasa.discover import DiscoveryResult from kasa.discover import DiscoveryResult
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.tapo.tapodevice import TapoDevice from kasa.smart import SmartDevice
Call = namedtuple("Call", "module method") Call = namedtuple("Call", "module method")
SmartCall = namedtuple("SmartCall", "module request should_succeed") SmartCall = namedtuple("SmartCall", "module request should_succeed")
@ -119,9 +119,9 @@ def default_to_regular(d):
return 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.""" """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) filename, copy_folder, final = await get_smart_fixture(device, batch_size)
else: else:
filename, copy_folder, final = await get_legacy_fixture(device) filename, copy_folder, final = await get_legacy_fixture(device)
@ -319,7 +319,7 @@ async def _make_requests_or_exit(
exit(1) 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.""" """Get fixture for new TAPO style protocol."""
extra_test_calls = [ extra_test_calls = [
SmartCall( SmartCall(

View File

@ -12,9 +12,13 @@ Module-specific errors are raised as `SmartDeviceException` and are expected
to be handled by the user of the library. to be handled by the user of the library.
""" """
from importlib.metadata import version from importlib.metadata import version
from typing import TYPE_CHECKING
from warnings import warn from warnings import warn
from kasa.bulb import Bulb
from kasa.credentials import Credentials from kasa.credentials import Credentials
from kasa.device import Device
from kasa.device_type import DeviceType
from kasa.deviceconfig import ( from kasa.deviceconfig import (
ConnectionType, ConnectionType,
DeviceConfig, DeviceConfig,
@ -29,18 +33,14 @@ from kasa.exceptions import (
TimeoutException, TimeoutException,
UnsupportedDeviceException, UnsupportedDeviceException,
) )
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import ( from kasa.iotprotocol import (
IotProtocol, IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401 _deprecated_TPLinkSmartHomeProtocol, # noqa: F401
) )
from kasa.plug import Plug
from kasa.protocol import BaseProtocol 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.smartprotocol import SmartProtocol
from kasa.smartstrip import SmartStrip
__version__ = version("python-kasa") __version__ = version("python-kasa")
@ -50,18 +50,15 @@ __all__ = [
"BaseProtocol", "BaseProtocol",
"IotProtocol", "IotProtocol",
"SmartProtocol", "SmartProtocol",
"SmartBulb", "BulbPreset",
"SmartBulbPreset",
"TurnOnBehaviors", "TurnOnBehaviors",
"TurnOnBehavior", "TurnOnBehavior",
"DeviceType", "DeviceType",
"EmeterStatus", "EmeterStatus",
"SmartDevice", "Device",
"Bulb",
"Plug",
"SmartDeviceException", "SmartDeviceException",
"SmartPlug",
"SmartStrip",
"SmartDimmer",
"SmartLightStrip",
"AuthenticationException", "AuthenticationException",
"UnsupportedDeviceException", "UnsupportedDeviceException",
"TimeoutException", "TimeoutException",
@ -72,11 +69,55 @@ __all__ = [
"DeviceFamilyType", "DeviceFamilyType",
] ]
from . import iot
deprecated_names = ["TPLinkSmartHomeProtocol"] 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): def __getattr__(name):
if name in deprecated_names: if name in deprecated_names:
warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1)
return globals()[f"_deprecated_{name}"] 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}") 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
kasa/bulb.py Normal file
View File

@ -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."""

View File

@ -13,21 +13,20 @@ import asyncclick as click
from kasa import ( from kasa import (
AuthenticationException, AuthenticationException,
Bulb,
ConnectionType, ConnectionType,
Credentials, Credentials,
Device,
DeviceConfig, DeviceConfig,
DeviceFamilyType, DeviceFamilyType,
Discover, Discover,
EncryptType, EncryptType,
SmartBulb, SmartDeviceException,
SmartDevice,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartStrip,
UnsupportedDeviceException, UnsupportedDeviceException,
) )
from kasa.discover import DiscoveryResult from kasa.discover import DiscoveryResult
from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip
from kasa.smart import SmartBulb, SmartDevice, SmartPlug
try: try:
from pydantic.v1 import ValidationError from pydantic.v1 import ValidationError
@ -62,11 +61,18 @@ echo = _do_echo
TYPE_TO_CLASS = { TYPE_TO_CLASS = {
"plug": SmartPlug, "plug": IotPlug,
"bulb": SmartBulb, "bulb": IotBulb,
"dimmer": SmartDimmer, "dimmer": IotDimmer,
"strip": SmartStrip, "strip": IotStrip,
"lightstrip": SmartLightStrip, "lightstrip": IotLightStrip,
"iot.plug": IotPlug,
"iot.bulb": IotBulb,
"iot.dimmer": IotDimmer,
"iot.strip": IotStrip,
"iot.lightstrip": IotLightStrip,
"smart.plug": SmartPlug,
"smart.bulb": SmartBulb,
} }
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType]
@ -80,7 +86,7 @@ SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"]
click.anyio_backend = "asyncio" click.anyio_backend = "asyncio"
pass_dev = click.make_pass_decorator(SmartDevice) pass_dev = click.make_pass_decorator(Device)
class ExceptionHandlerGroup(click.Group): class ExceptionHandlerGroup(click.Group):
@ -110,8 +116,8 @@ def json_formatter_cb(result, **kwargs):
""" """
return str(val) return str(val)
@to_serializable.register(SmartDevice) @to_serializable.register(Device)
def _device_to_serializable(val: SmartDevice): def _device_to_serializable(val: Device):
"""Serialize smart device data, just using the last update raw payload.""" """Serialize smart device data, just using the last update raw payload."""
return val.internal_state return val.internal_state
@ -261,7 +267,7 @@ async def cli(
# no need to perform any checks if we are just displaying the help # no need to perform any checks if we are just displaying the help
if sys.argv[-1] == "--help": if sys.argv[-1] == "--help":
# Context object is required to avoid crashing on sub-groups # Context object is required to avoid crashing on sub-groups
ctx.obj = SmartDevice(None) ctx.obj = Device(None)
return return
# If JSON output is requested, disable echo # If JSON output is requested, disable echo
@ -340,7 +346,7 @@ async def cli(
timeout=timeout, timeout=timeout,
connection_type=ctype, connection_type=ctype,
) )
dev = await SmartDevice.connect(config=config) dev = await Device.connect(config=config)
else: else:
echo("No --type or --device-family and --encrypt-type defined, discovering..") echo("No --type or --device-family and --encrypt-type defined, discovering..")
dev = await Discover.discover_single( dev = await Discover.discover_single(
@ -384,7 +390,7 @@ async def scan(dev):
@click.option("--keytype", prompt=True) @click.option("--keytype", prompt=True)
@click.option("--password", prompt=True, hide_input=True) @click.option("--password", prompt=True, hide_input=True)
@pass_dev @pass_dev
async def join(dev: SmartDevice, ssid: str, password: str, keytype: str): async def join(dev: Device, ssid: str, password: str, keytype: str):
"""Join the given wifi network.""" """Join the given wifi network."""
echo(f"Asking the device to connect to {ssid}..") echo(f"Asking the device to connect to {ssid}..")
res = await dev.wifi_join(ssid, password, keytype=keytype) res = await dev.wifi_join(ssid, password, keytype=keytype)
@ -428,7 +434,7 @@ async def discover(ctx):
echo(f"Discovering devices on {target} for {discovery_timeout} seconds") echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
async def print_discovered(dev: SmartDevice): async def print_discovered(dev: Device):
async with sem: async with sem:
try: try:
await dev.update() await dev.update()
@ -526,7 +532,7 @@ async def sysinfo(dev):
@cli.command() @cli.command()
@pass_dev @pass_dev
@click.pass_context @click.pass_context
async def state(ctx, dev: SmartDevice): async def state(ctx, dev: Device):
"""Print out device state and versions.""" """Print out device state and versions."""
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
@ -589,7 +595,6 @@ async def alias(dev, new_alias, index):
if not dev.is_strip: if not dev.is_strip:
echo("Index can only used for power strips!") echo("Index can only used for power strips!")
return return
dev = cast(SmartStrip, dev)
dev = dev.get_plug_by_index(index) dev = dev.get_plug_by_index(index)
if new_alias is not None: if new_alias is not None:
@ -611,7 +616,7 @@ async def alias(dev, new_alias, index):
@click.argument("module") @click.argument("module")
@click.argument("command") @click.argument("command")
@click.argument("parameters", default=None, required=False) @click.argument("parameters", default=None, required=False)
async def raw_command(ctx, dev: SmartDevice, module, command, parameters): async def raw_command(ctx, dev: Device, module, command, parameters):
"""Run a raw command on the device.""" """Run a raw command on the device."""
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
return await ctx.forward(cmd_command) return await ctx.forward(cmd_command)
@ -622,12 +627,17 @@ async def raw_command(ctx, dev: SmartDevice, module, command, parameters):
@click.option("--module", required=False, help="Module for IOT protocol.") @click.option("--module", required=False, help="Module for IOT protocol.")
@click.argument("command") @click.argument("command")
@click.argument("parameters", default=None, required=False) @click.argument("parameters", default=None, required=False)
async def cmd_command(dev: SmartDevice, module, command, parameters): async def cmd_command(dev: Device, module, command, parameters):
"""Run a raw command on the device.""" """Run a raw command on the device."""
if parameters is not None: if parameters is not None:
parameters = ast.literal_eval(parameters) parameters = ast.literal_eval(parameters)
res = await dev._query_helper(module, command, parameters) if isinstance(dev, IotDevice):
res = await dev._query_helper(module, command, parameters)
elif isinstance(dev, SmartDevice):
res = await dev._query_helper(command, parameters)
else:
raise SmartDeviceException("Unexpected device type %s.", dev)
echo(json.dumps(res)) echo(json.dumps(res))
return res return res
@ -639,7 +649,7 @@ async def cmd_command(dev: SmartDevice, module, command, parameters):
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True) @click.option("--erase", is_flag=True)
async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): async def emeter(dev: Device, index: int, name: str, year, month, erase):
"""Query emeter for historical consumption. """Query emeter for historical consumption.
Daily and monthly data provided in CSV format. Daily and monthly data provided in CSV format.
@ -649,7 +659,6 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase):
echo("Index and name are only for power strips!") echo("Index and name are only for power strips!")
return return
dev = cast(SmartStrip, dev)
if index is not None: if index is not None:
dev = dev.get_plug_by_index(index) dev = dev.get_plug_by_index(index)
elif name: elif name:
@ -660,6 +669,12 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase):
echo("Device has no emeter") echo("Device has no emeter")
return return
if (year or month or erase) and not isinstance(dev, IotDevice):
echo("Device has no historical statistics")
return
else:
dev = cast(IotDevice, dev)
if erase: if erase:
echo("Erasing emeter statistics..") echo("Erasing emeter statistics..")
return await dev.erase_emeter_stats() return await dev.erase_emeter_stats()
@ -701,7 +716,7 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase):
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True) @click.option("--erase", is_flag=True)
async def usage(dev: SmartDevice, year, month, erase): async def usage(dev: Device, year, month, erase):
"""Query usage for historical consumption. """Query usage for historical consumption.
Daily and monthly data provided in CSV format. Daily and monthly data provided in CSV format.
@ -739,7 +754,7 @@ async def usage(dev: SmartDevice, year, month, erase):
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def brightness(dev: SmartBulb, brightness: int, transition: int): async def brightness(dev: Bulb, brightness: int, transition: int):
"""Get or set brightness.""" """Get or set brightness."""
if not dev.is_dimmable: if not dev.is_dimmable:
echo("This device does not support brightness.") echo("This device does not support brightness.")
@ -759,7 +774,7 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int):
) )
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def temperature(dev: SmartBulb, temperature: int, transition: int): async def temperature(dev: Bulb, temperature: int, transition: int):
"""Get or set color temperature.""" """Get or set color temperature."""
if not dev.is_variable_color_temp: if not dev.is_variable_color_temp:
echo("Device does not support color temperature") echo("Device does not support color temperature")
@ -852,14 +867,13 @@ async def time(dev):
@click.option("--name", type=str, required=False) @click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def on(dev: SmartDevice, index: int, name: str, transition: int): async def on(dev: Device, index: int, name: str, transition: int):
"""Turn the device on.""" """Turn the device on."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.is_strip:
echo("Index and name are only for power strips!") echo("Index and name are only for power strips!")
return return
dev = cast(SmartStrip, dev)
if index is not None: if index is not None:
dev = dev.get_plug_by_index(index) dev = dev.get_plug_by_index(index)
elif name: elif name:
@ -874,14 +888,13 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int):
@click.option("--name", type=str, required=False) @click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def off(dev: SmartDevice, index: int, name: str, transition: int): async def off(dev: Device, index: int, name: str, transition: int):
"""Turn the device off.""" """Turn the device off."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.is_strip:
echo("Index and name are only for power strips!") echo("Index and name are only for power strips!")
return return
dev = cast(SmartStrip, dev)
if index is not None: if index is not None:
dev = dev.get_plug_by_index(index) dev = dev.get_plug_by_index(index)
elif name: elif name:
@ -896,14 +909,13 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int):
@click.option("--name", type=str, required=False) @click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def toggle(dev: SmartDevice, index: int, name: str, transition: int): async def toggle(dev: Device, index: int, name: str, transition: int):
"""Toggle the device on/off.""" """Toggle the device on/off."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.is_strip:
echo("Index and name are only for power strips!") echo("Index and name are only for power strips!")
return return
dev = cast(SmartStrip, dev)
if index is not None: if index is not None:
dev = dev.get_plug_by_index(index) dev = dev.get_plug_by_index(index)
elif name: elif name:
@ -970,10 +982,10 @@ async def presets(ctx):
@presets.command(name="list") @presets.command(name="list")
@pass_dev @pass_dev
def presets_list(dev: SmartBulb): def presets_list(dev: IotBulb):
"""List presets.""" """List presets."""
if not dev.is_bulb: if not dev.is_bulb or not isinstance(dev, IotBulb):
echo("Presets only supported on bulbs") echo("Presets only supported on iot bulbs")
return return
for preset in dev.presets: for preset in dev.presets:
@ -989,9 +1001,7 @@ def presets_list(dev: SmartBulb):
@click.option("--saturation", type=int) @click.option("--saturation", type=int)
@click.option("--temperature", type=int) @click.option("--temperature", type=int)
@pass_dev @pass_dev
async def presets_modify( async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, temperature):
dev: SmartBulb, index, brightness, hue, saturation, temperature
):
"""Modify a preset.""" """Modify a preset."""
for preset in dev.presets: for preset in dev.presets:
if preset.index == index: if preset.index == index:
@ -1019,8 +1029,11 @@ async def presets_modify(
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
@click.option("--last", is_flag=True) @click.option("--last", is_flag=True)
@click.option("--preset", type=int) @click.option("--preset", type=int)
async def turn_on_behavior(dev: SmartBulb, type, last, preset): async def turn_on_behavior(dev: IotBulb, type, last, preset):
"""Modify bulb turn-on behavior.""" """Modify bulb turn-on behavior."""
if not dev.is_bulb or not isinstance(dev, IotBulb):
echo("Presets only supported on iot bulbs")
return
settings = await dev.get_turn_on_behavior() settings = await dev.get_turn_on_behavior()
echo(f"Current turn on behavior: {settings}") echo(f"Current turn on behavior: {settings}")
@ -1055,10 +1068,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset):
) )
async def update_credentials(dev, username, password): async def update_credentials(dev, username, password):
"""Update device credentials for authenticated devices.""" """Update device credentials for authenticated devices."""
# Importing here as this is not really a public interface for now if not isinstance(dev, SmartDevice):
from kasa.tapo import TapoDevice
if not isinstance(dev, TapoDevice):
raise NotImplementedError( raise NotImplementedError(
"Credentials can only be updated on authenticated devices." "Credentials can only be updated on authenticated devices."
) )

353
kasa/device.py Normal file
View File

@ -0,0 +1,353 @@
"""Module for Device base class."""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Set, Union
from .credentials import Credentials
from .device_type import DeviceType
from .deviceconfig import DeviceConfig
from .emeterstatus import EmeterStatus
from .exceptions import SmartDeviceException
from .iotprotocol import IotProtocol
from .protocol import BaseProtocol
from .xortransport import XorTransport
@dataclass
class WifiNetwork:
"""Wifi network container."""
ssid: str
key_type: int
# These are available only on softaponboarding
cipher_type: Optional[int] = None
bssid: Optional[str] = None
channel: Optional[int] = None
rssi: Optional[int] = None
# For SMART devices
signal_level: Optional[int] = None
_LOGGER = logging.getLogger(__name__)
class Device(ABC):
"""Common device interface.
Do not instantiate this class directly, instead get a device instance from
:func:`Device.connect()`, :func:`Discover.discover()`
or :func:`Discover.discover_single()`.
"""
def __init__(
self,
host: str,
*,
config: Optional[DeviceConfig] = None,
protocol: Optional[BaseProtocol] = None,
) -> None:
"""Create a new Device instance.
:param str host: host name or IP address of the device
:param DeviceConfig config: device configuration
:param BaseProtocol protocol: protocol for communicating with the device
"""
if config and protocol:
protocol._transport._config = config
self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)),
)
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using Optional[Dict] would require separate
# checks in accessors. the @updated_required decorator does not ensure
# mypy that these are not accessed incorrectly.
self._last_update: Any = None
self._discovery_info: Optional[Dict[str, Any]] = None
self.modules: Dict[str, Any] = {}
@staticmethod
async def connect(
*,
host: Optional[str] = None,
config: Optional[DeviceConfig] = None,
) -> "Device":
"""Connect to a single device by the given hostname or device configuration.
This method avoids the UDP based discovery process and
will connect directly to the device.
It is generally preferred to avoid :func:`discover_single()` and
use this function instead as it should perform better when
the WiFi network is congested or the device is not responding
to discovery requests.
:param host: Hostname of device to query
:param config: Connection parameters to ensure the correct protocol
and connection options are used.
:rtype: SmartDevice
:return: Object for querying/controlling found device.
"""
from .device_factory import connect # pylint: disable=import-outside-toplevel
return await connect(host=host, config=config) # type: ignore[arg-type]
@abstractmethod
async def update(self, update_children: bool = True):
"""Update the device."""
async def disconnect(self):
"""Disconnect and close any underlying connection resources."""
await self.protocol.close()
@property
@abstractmethod
def is_on(self) -> bool:
"""Return true if the device is on."""
@property
def is_off(self) -> bool:
"""Return True if device is off."""
return not self.is_on
@abstractmethod
async def turn_on(self, **kwargs) -> Optional[Dict]:
"""Turn on the device."""
@abstractmethod
async def turn_off(self, **kwargs) -> Optional[Dict]:
"""Turn off the device."""
@property
def host(self) -> str:
"""The device host."""
return self.protocol._transport._host
@host.setter
def host(self, value):
"""Set the device host.
Generally used by discovery to set the hostname after ip discovery.
"""
self.protocol._transport._host = value
self.protocol._transport._config.host = value
@property
def port(self) -> int:
"""The device port."""
return self.protocol._transport._port
@property
def credentials(self) -> Optional[Credentials]:
"""The device credentials."""
return self.protocol._transport._credentials
@property
def credentials_hash(self) -> Optional[str]:
"""The protocol specific hash of the credentials the device is using."""
return self.protocol._transport.credentials_hash
@property
def device_type(self) -> DeviceType:
"""Return the device type."""
return self._device_type
@abstractmethod
def update_from_discover_info(self, info):
"""Update state from info from the discover call."""
@property
def config(self) -> DeviceConfig:
"""Return the device configuration."""
return self.protocol.config
@property
@abstractmethod
def model(self) -> str:
"""Returns the device model."""
@property
@abstractmethod
def alias(self) -> Optional[str]:
"""Returns the device alias or nickname."""
async def _raw_query(self, request: Union[str, Dict]) -> Any:
"""Send a raw query to the device."""
return await self.protocol.query(request=request)
@property
@abstractmethod
def children(self) -> Sequence["Device"]:
"""Returns the child devices."""
@property
@abstractmethod
def sys_info(self) -> Dict[str, Any]:
"""Returns the device info."""
@property
def is_bulb(self) -> bool:
"""Return True if the device is a bulb."""
return self._device_type == DeviceType.Bulb
@property
def is_light_strip(self) -> bool:
"""Return True if the device is a led strip."""
return self._device_type == DeviceType.LightStrip
@property
def is_plug(self) -> bool:
"""Return True if the device is a plug."""
return self._device_type == DeviceType.Plug
@property
def is_strip(self) -> bool:
"""Return True if the device is a strip."""
return self._device_type == DeviceType.Strip
@property
def is_strip_socket(self) -> bool:
"""Return True if the device is a strip socket."""
return self._device_type == DeviceType.StripSocket
@property
def is_dimmer(self) -> bool:
"""Return True if the device is a dimmer."""
return self._device_type == DeviceType.Dimmer
@property
def is_dimmable(self) -> bool:
"""Return True if the device is dimmable."""
return False
@property
def is_variable_color_temp(self) -> bool:
"""Return True if the device supports color temperature."""
return False
@property
def is_color(self) -> bool:
"""Return True if the device supports color changes."""
return False
def get_plug_by_name(self, name: str) -> "Device":
"""Return child device for the given name."""
for p in self.children:
if p.alias == name:
return p
raise SmartDeviceException(f"Device has no child with {name}")
def get_plug_by_index(self, index: int) -> "Device":
"""Return child device for the given index."""
if index + 1 > len(self.children) or index < 0:
raise SmartDeviceException(
f"Invalid index {index}, device has {len(self.children)} plugs"
)
return self.children[index]
@property
@abstractmethod
def time(self) -> datetime:
"""Return the time."""
@property
@abstractmethod
def timezone(self) -> Dict:
"""Return the timezone and time_difference."""
@property
@abstractmethod
def hw_info(self) -> Dict:
"""Return hardware info for the device."""
@property
@abstractmethod
def location(self) -> Dict:
"""Return the device location."""
@property
@abstractmethod
def rssi(self) -> Optional[int]:
"""Return the rssi."""
@property
@abstractmethod
def mac(self) -> str:
"""Return the mac formatted with colons."""
@property
@abstractmethod
def device_id(self) -> str:
"""Return the device id."""
@property
@abstractmethod
def internal_state(self) -> Any:
"""Return all the internal state data."""
@property
@abstractmethod
def state_information(self) -> Dict[str, Any]:
"""Return the key state information."""
@property
@abstractmethod
def features(self) -> Set[str]:
"""Return the list of supported features."""
@property
@abstractmethod
def has_emeter(self) -> bool:
"""Return if the device has emeter."""
@property
@abstractmethod
def on_since(self) -> Optional[datetime]:
"""Return the time that the device was turned on or None if turned off."""
@abstractmethod
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
@property
@abstractmethod
def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status."""
@property
@abstractmethod
def emeter_this_month(self) -> Optional[float]:
"""Get the emeter value for this month."""
@property
@abstractmethod
def emeter_today(self) -> Union[Optional[float], Any]:
"""Get the emeter value for today."""
# Return type of Any ensures consumers being shielded from the return
# type by @update_required are not affected.
@abstractmethod
async def wifi_scan(self) -> List[WifiNetwork]:
"""Scan for available wifi networks."""
@abstractmethod
async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"):
"""Join the given wifi network."""
@abstractmethod
async def set_alias(self, alias: str):
"""Set the device name (alias)."""
def __repr__(self):
if self._last_update is None:
return f"<{self._device_type} at {self.host} - update() needed>"
return (
f"<{self._device_type} model {self.model} at {self.host}"
f" ({self.alias}), is_on: {self.is_on}"
f" - dev specific: {self.state_information}>"
)

View File

@ -4,22 +4,18 @@ import time
from typing import Any, Dict, Optional, Tuple, Type from typing import Any, Dict, Optional, Tuple, Type
from .aestransport import AesTransport from .aestransport import AesTransport
from .device import Device
from .deviceconfig import DeviceConfig from .deviceconfig import DeviceConfig
from .exceptions import SmartDeviceException, UnsupportedDeviceException from .exceptions import SmartDeviceException, UnsupportedDeviceException
from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .klaptransport import KlapTransport, KlapTransportV2 from .klaptransport import KlapTransport, KlapTransportV2
from .protocol import ( from .protocol import (
BaseProtocol, BaseProtocol,
BaseTransport, BaseTransport,
) )
from .smartbulb import SmartBulb from .smart import SmartBulb, SmartPlug
from .smartdevice import SmartDevice
from .smartdimmer import SmartDimmer
from .smartlightstrip import SmartLightStrip
from .smartplug import SmartPlug
from .smartprotocol import SmartProtocol from .smartprotocol import SmartProtocol
from .smartstrip import SmartStrip
from .tapo import TapoBulb, TapoPlug
from .xortransport import XorTransport from .xortransport import XorTransport
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,7 +25,7 @@ GET_SYSINFO_QUERY = {
} }
async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Device":
"""Connect to a single device by the given hostname or device configuration. """Connect to a single device by the given hostname or device configuration.
This method avoids the UDP based discovery process and This method avoids the UDP based discovery process and
@ -73,7 +69,8 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart
+ f"{config.connection_type.device_family.value}" + f"{config.connection_type.device_family.value}"
) )
device_class: Optional[Type[SmartDevice]] device_class: Optional[Type[Device]]
device: Optional[Device] = None
if isinstance(protocol, IotProtocol) and isinstance( if isinstance(protocol, IotProtocol) and isinstance(
protocol._transport, XorTransport protocol._transport, XorTransport
@ -100,7 +97,7 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart
) )
def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "system" not in info or "get_sysinfo" not in info["system"]: if "system" not in info or "get_sysinfo" not in info["system"]:
raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") raise SmartDeviceException("No 'system' or 'get_sysinfo' in response")
@ -111,32 +108,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]:
raise SmartDeviceException("Unable to find the device type field!") raise SmartDeviceException("Unable to find the device type field!")
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
return SmartDimmer return IotDimmer
if "smartplug" in type_.lower(): if "smartplug" in type_.lower():
if "children" in sysinfo: if "children" in sysinfo:
return SmartStrip return IotStrip
return SmartPlug return IotPlug
if "smartbulb" in type_.lower(): if "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length if "length" in sysinfo: # strips have length
return SmartLightStrip return IotLightStrip
return SmartBulb return IotBulb
raise UnsupportedDeviceException("Unknown device type: %s" % type_) raise UnsupportedDeviceException("Unknown device type: %s" % type_)
def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]:
"""Return the device class from the type name.""" """Return the device class from the type name."""
supported_device_types: Dict[str, Type[SmartDevice]] = { supported_device_types: Dict[str, Type[Device]] = {
"SMART.TAPOPLUG": TapoPlug, "SMART.TAPOPLUG": SmartPlug,
"SMART.TAPOBULB": TapoBulb, "SMART.TAPOBULB": SmartBulb,
"SMART.TAPOSWITCH": TapoBulb, "SMART.TAPOSWITCH": SmartBulb,
"SMART.KASAPLUG": TapoPlug, "SMART.KASAPLUG": SmartPlug,
"SMART.KASASWITCH": TapoBulb, "SMART.KASASWITCH": SmartBulb,
"IOT.SMARTPLUGSWITCH": SmartPlug, "IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": SmartBulb, "IOT.SMARTBULB": IotBulb,
} }
return supported_device_types.get(device_type) return supported_device_types.get(device_type)

View File

@ -14,8 +14,6 @@ class DeviceType(Enum):
StripSocket = "stripsocket" StripSocket = "stripsocket"
Dimmer = "dimmer" Dimmer = "dimmer"
LightStrip = "lightstrip" LightStrip = "lightstrip"
TapoPlug = "tapoplug"
TapoBulb = "tapobulb"
Unknown = "unknown" Unknown = "unknown"
@staticmethod @staticmethod

View File

@ -15,6 +15,7 @@ try:
except ImportError: except ImportError:
from pydantic import BaseModel, ValidationError # pragma: no cover from pydantic import BaseModel, ValidationError # pragma: no cover
from kasa import Device
from kasa.credentials import Credentials from kasa.credentials import Credentials
from kasa.device_factory import ( from kasa.device_factory import (
get_device_class_from_family, get_device_class_from_family,
@ -22,17 +23,21 @@ from kasa.device_factory import (
get_protocol, get_protocol,
) )
from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType
from kasa.exceptions import TimeoutException, UnsupportedDeviceException from kasa.exceptions import (
SmartDeviceException,
TimeoutException,
UnsupportedDeviceException,
)
from kasa.iot.iotdevice import IotDevice
from kasa.json import dumps as json_dumps from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads from kasa.json import loads as json_loads
from kasa.smartdevice import SmartDevice, SmartDeviceException
from kasa.xortransport import XorEncryption from kasa.xortransport import XorEncryption
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] OnDiscoveredCallable = Callable[[Device], Awaitable[None]]
DeviceDict = Dict[str, SmartDevice] DeviceDict = Dict[str, Device]
class _DiscoverProtocol(asyncio.DatagramProtocol): class _DiscoverProtocol(asyncio.DatagramProtocol):
@ -121,7 +126,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
return return
self.seen_hosts.add(ip) self.seen_hosts.add(ip)
device = None device: Optional[Device] = None
config = DeviceConfig(host=ip, port_override=self.port) config = DeviceConfig(host=ip, port_override=self.port)
if self.credentials: if self.credentials:
@ -300,7 +305,7 @@ class Discover:
port: Optional[int] = None, port: Optional[int] = None,
timeout: Optional[int] = None, timeout: Optional[int] = None,
credentials: Optional[Credentials] = None, credentials: Optional[Credentials] = None,
) -> SmartDevice: ) -> Device:
"""Discover a single device by the given IP address. """Discover a single device by the given IP address.
It is generally preferred to avoid :func:`discover_single()` and It is generally preferred to avoid :func:`discover_single()` and
@ -382,7 +387,7 @@ class Discover:
raise SmartDeviceException(f"Unable to get discovery response for {host}") raise SmartDeviceException(f"Unable to get discovery response for {host}")
@staticmethod @staticmethod
def _get_device_class(info: dict) -> Type[SmartDevice]: def _get_device_class(info: dict) -> Type[Device]:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "result" in info: if "result" in info:
discovery_result = DiscoveryResult(**info["result"]) discovery_result = DiscoveryResult(**info["result"])
@ -397,7 +402,7 @@ class Discover:
return get_device_class_from_sys_info(info) return get_device_class_from_sys_info(info)
@staticmethod @staticmethod
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice:
"""Get SmartDevice from legacy 9999 response.""" """Get SmartDevice from legacy 9999 response."""
try: try:
info = json_loads(XorEncryption.decrypt(data)) info = json_loads(XorEncryption.decrypt(data))
@ -408,7 +413,7 @@ class Discover:
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info)
device_class = Discover._get_device_class(info) device_class = cast(Type[IotDevice], Discover._get_device_class(info))
device = device_class(config.host, config=config) device = device_class(config.host, config=config)
sys_info = info["system"]["get_sysinfo"] sys_info = info["system"]["get_sysinfo"]
if device_type := sys_info.get("mic_type", sys_info.get("type")): if device_type := sys_info.get("mic_type", sys_info.get("type")):
@ -423,7 +428,7 @@ class Discover:
def _get_device_instance( def _get_device_instance(
data: bytes, data: bytes,
config: DeviceConfig, config: DeviceConfig,
) -> SmartDevice: ) -> Device:
"""Get SmartDevice from the new 20002 response.""" """Get SmartDevice from the new 20002 response."""
try: try:
info = json_loads(data[16:]) info = json_loads(data[16:])

16
kasa/iot/__init__.py Normal file
View File

@ -0,0 +1,16 @@
"""Package for supporting legacy kasa devices."""
from .iotbulb import IotBulb
from .iotdevice import IotDevice
from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip
from .iotplug import IotPlug
from .iotstrip import IotStrip
__all__ = [
"IotDevice",
"IotPlug",
"IotBulb",
"IotStrip",
"IotDimmer",
"IotLightStrip",
]

View File

@ -2,49 +2,19 @@
import logging import logging
import re import re
from enum import Enum from enum import Enum
from typing import Any, Dict, List, NamedTuple, Optional, cast from typing import Any, Dict, List, Optional, cast
try: try:
from pydantic.v1 import BaseModel, Field, root_validator from pydantic.v1 import BaseModel, Field, root_validator
except ImportError: except ImportError:
from pydantic import BaseModel, Field, root_validator from pydantic import BaseModel, Field, root_validator
from .deviceconfig import DeviceConfig from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, SmartDeviceException, requires_update
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
from .protocol import BaseProtocol
from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update
class ColorTempRange(NamedTuple):
"""Color temperature range."""
min: int
max: int
class HSV(NamedTuple):
"""Hue-saturation-value."""
hue: int
saturation: int
value: int
class SmartBulbPreset(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 BehaviorMode(str, Enum): class BehaviorMode(str, Enum):
@ -116,7 +86,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class SmartBulb(SmartDevice): class IotBulb(IotDevice, Bulb):
r"""Representation of a TP-Link Smart Bulb. r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once. To initialize, you have to await :func:`update()` at least once.
@ -132,7 +102,7 @@ class SmartBulb(SmartDevice):
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> bulb = SmartBulb("127.0.0.1") >>> bulb = IotBulb("127.0.0.1")
>>> asyncio.run(bulb.update()) >>> asyncio.run(bulb.update())
>>> print(bulb.alias) >>> print(bulb.alias)
Bulb2 Bulb2
@ -198,7 +168,7 @@ class SmartBulb(SmartDevice):
Bulb configuration presets can be accessed using the :func:`presets` property: Bulb configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets >>> bulb.presets
[SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] [BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
instance to :func:`save_preset` method: instance to :func:`save_preset` method:
@ -373,10 +343,6 @@ class SmartBulb(SmartDevice):
return HSV(hue, saturation, value) return HSV(hue, saturation, value)
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%)")
@requires_update @requires_update
async def set_hsv( async def set_hsv(
self, self,
@ -534,11 +500,11 @@ class SmartBulb(SmartDevice):
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def presets(self) -> List[SmartBulbPreset]: def presets(self) -> List[BulbPreset]:
"""Return a list of available bulb setting presets.""" """Return a list of available bulb setting presets."""
return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]] return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]]
async def save_preset(self, preset: SmartBulbPreset): async def save_preset(self, preset: BulbPreset):
"""Save a setting preset. """Save a setting preset.
You can either construct a preset object manually, or pass an existing one You can either construct a preset object manually, or pass an existing one

View File

@ -15,39 +15,19 @@ import collections.abc
import functools import functools
import inspect import inspect
import logging import logging
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Sequence, Set
from .credentials import Credentials from ..device import Device, WifiNetwork
from .device_type import DeviceType from ..deviceconfig import DeviceConfig
from .deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus
from .emeterstatus import EmeterStatus from ..exceptions import SmartDeviceException
from .exceptions import SmartDeviceException from ..protocol import BaseProtocol
from .iotprotocol import IotProtocol from .modules import Emeter, IotModule
from .modules import Emeter, Module
from .protocol import BaseProtocol
from .xortransport import XorTransport
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass
class WifiNetwork:
"""Wifi network container."""
ssid: str
key_type: int
# These are available only on softaponboarding
cipher_type: Optional[int] = None
bssid: Optional[str] = None
channel: Optional[int] = None
rssi: Optional[int] = None
# For SMART devices
signal_level: Optional[int] = None
def merge(d, u): def merge(d, u):
"""Update dict recursively.""" """Update dict recursively."""
for k, v in u.items(): for k, v in u.items():
@ -92,17 +72,17 @@ def _parse_features(features: str) -> Set[str]:
return set(features.split(":")) return set(features.split(":"))
class SmartDevice: class IotDevice(Device):
"""Base class for all supported device types. """Base class for all supported device types.
You don't usually want to initialize this class manually, You don't usually want to initialize this class manually,
but either use :class:`Discover` class, or use one of the subclasses: but either use :class:`Discover` class, or use one of the subclasses:
* :class:`SmartPlug` * :class:`IotPlug`
* :class:`SmartBulb` * :class:`IotBulb`
* :class:`SmartStrip` * :class:`IotStrip`
* :class:`SmartDimmer` * :class:`IotDimmer`
* :class:`SmartLightStrip` * :class:`IotLightStrip`
To initialize, you have to await :func:`update()` at least once. To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
@ -115,7 +95,7 @@ class SmartDevice:
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> dev = SmartDevice("127.0.0.1") >>> dev = IotDevice("127.0.0.1")
>>> asyncio.run(dev.update()) >>> asyncio.run(dev.update())
All devices provide several informational properties: All devices provide several informational properties:
@ -200,59 +180,24 @@ class SmartDevice:
config: Optional[DeviceConfig] = None, config: Optional[DeviceConfig] = None,
protocol: Optional[BaseProtocol] = None, protocol: Optional[BaseProtocol] = None,
) -> None: ) -> None:
"""Create a new SmartDevice instance. """Create a new IotDevice instance."""
super().__init__(host=host, config=config, protocol=protocol)
:param str host: host name or ip address on which the device listens
"""
if config and protocol:
protocol._transport._config = config
self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)),
)
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using Optional[Dict] would require separate
# checks in accessors. the @updated_required decorator does not ensure
# mypy that these are not accessed incorrectly.
self._last_update: Any = None
self._discovery_info: Optional[Dict[str, Any]] = None
self._sys_info: Any = None # TODO: this is here to avoid changing tests self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._features: Set[str] = set() self._features: Set[str] = set()
self.modules: Dict[str, Any] = {} self._children: Sequence["IotDevice"] = []
self.children: List["SmartDevice"] = []
@property @property
def host(self) -> str: def children(self) -> Sequence["IotDevice"]:
"""The device host.""" """Return list of children."""
return self.protocol._transport._host return self._children
@host.setter @children.setter
def host(self, value): def children(self, children):
"""Set the device host. """Initialize from a list of children."""
self._children = children
Generally used by discovery to set the hostname after ip discovery. def add_module(self, name: str, module: IotModule):
"""
self.protocol._transport._host = value
self.protocol._transport._config.host = value
@property
def port(self) -> int:
"""The device port."""
return self.protocol._transport._port
@property
def credentials(self) -> Optional[Credentials]:
"""The device credentials."""
return self.protocol._transport._credentials
@property
def credentials_hash(self) -> Optional[str]:
"""The protocol specific hash of the credentials the device is using."""
return self.protocol._transport.credentials_hash
def add_module(self, name: str, module: Module):
"""Register a module.""" """Register a module."""
if name in self.modules: if name in self.modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name) _LOGGER.debug("Module %s already registered, ignoring..." % name)
@ -291,7 +236,7 @@ class SmartDevice:
request = self._create_request(target, cmd, arg, child_ids) request = self._create_request(target, cmd, arg, child_ids)
try: try:
response = await self.protocol.query(request=request) response = await self._raw_query(request=request)
except Exception as ex: except Exception as ex:
raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex
@ -631,13 +576,7 @@ class SmartDevice:
"""Turn off the device.""" """Turn off the device."""
raise NotImplementedError("Device subclass needs to implement this.") raise NotImplementedError("Device subclass needs to implement this.")
@property # type: ignore async def turn_on(self, **kwargs) -> Optional[Dict]:
@requires_update
def is_off(self) -> bool:
"""Return True if device is off."""
return not self.is_on
async def turn_on(self, **kwargs) -> Dict:
"""Turn device on.""" """Turn device on."""
raise NotImplementedError("Device subclass needs to implement this.") raise NotImplementedError("Device subclass needs to implement this.")
@ -714,77 +653,11 @@ class SmartDevice:
) )
return await _join("smartlife.iot.common.softaponboarding", payload) return await _join("smartlife.iot.common.softaponboarding", payload)
def get_plug_by_name(self, name: str) -> "SmartDevice":
"""Return child device for the given name."""
for p in self.children:
if p.alias == name:
return p
raise SmartDeviceException(f"Device has no child with {name}")
def get_plug_by_index(self, index: int) -> "SmartDevice":
"""Return child device for the given index."""
if index + 1 > len(self.children) or index < 0:
raise SmartDeviceException(
f"Invalid index {index}, device has {len(self.children)} plugs"
)
return self.children[index]
@property @property
def max_device_response_size(self) -> int: def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct.""" """Returns the maximum response size the device can safely construct."""
return 16 * 1024 return 16 * 1024
@property
def device_type(self) -> DeviceType:
"""Return the device type."""
return self._device_type
@property
def is_bulb(self) -> bool:
"""Return True if the device is a bulb."""
return self._device_type == DeviceType.Bulb
@property
def is_light_strip(self) -> bool:
"""Return True if the device is a led strip."""
return self._device_type == DeviceType.LightStrip
@property
def is_plug(self) -> bool:
"""Return True if the device is a plug."""
return self._device_type == DeviceType.Plug
@property
def is_strip(self) -> bool:
"""Return True if the device is a strip."""
return self._device_type == DeviceType.Strip
@property
def is_strip_socket(self) -> bool:
"""Return True if the device is a strip socket."""
return self._device_type == DeviceType.StripSocket
@property
def is_dimmer(self) -> bool:
"""Return True if the device is a dimmer."""
return self._device_type == DeviceType.Dimmer
@property
def is_dimmable(self) -> bool:
"""Return True if the device is dimmable."""
return False
@property
def is_variable_color_temp(self) -> bool:
"""Return True if the device supports color temperature."""
return False
@property
def is_color(self) -> bool:
"""Return True if the device supports color changes."""
return False
@property @property
def internal_state(self) -> Any: def internal_state(self) -> Any:
"""Return the internal state of the instance. """Return the internal state of the instance.
@ -793,47 +666,3 @@ class SmartDevice:
This should only be used for debugging purposes. This should only be used for debugging purposes.
""" """
return self._last_update or self._discovery_info return self._last_update or self._discovery_info
def __repr__(self):
if self._last_update is None:
return f"<{self._device_type} at {self.host} - update() needed>"
return (
f"<{self._device_type} model {self.model} at {self.host}"
f" ({self.alias}), is_on: {self.is_on}"
f" - dev specific: {self.state_information}>"
)
@property
def config(self) -> DeviceConfig:
"""Return the device configuration."""
return self.protocol.config
async def disconnect(self):
"""Disconnect and close any underlying connection resources."""
await self.protocol.close()
@staticmethod
async def connect(
*,
host: Optional[str] = None,
config: Optional[DeviceConfig] = None,
) -> "SmartDevice":
"""Connect to a single device by the given hostname or device configuration.
This method avoids the UDP based discovery process and
will connect directly to the device.
It is generally preferred to avoid :func:`discover_single()` and
use this function instead as it should perform better when
the WiFi network is congested or the device is not responding
to discovery requests.
:param host: Hostname of device to query
:param config: Connection parameters to ensure the correct protocol
and connection options are used.
:rtype: SmartDevice
:return: Object for querying/controlling found device.
"""
from .device_factory import connect # pylint: disable=import-outside-toplevel
return await connect(host=host, config=config) # type: ignore[arg-type]

View File

@ -2,11 +2,12 @@
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from kasa.deviceconfig import DeviceConfig from ..device_type import DeviceType
from kasa.modules import AmbientLight, Motion from ..deviceconfig import DeviceConfig
from kasa.protocol import BaseProtocol from ..protocol import BaseProtocol
from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from .iotdevice import SmartDeviceException, requires_update
from kasa.smartplug import SmartPlug from .iotplug import IotPlug
from .modules import AmbientLight, Motion
class ButtonAction(Enum): class ButtonAction(Enum):
@ -32,7 +33,7 @@ class FadeType(Enum):
FadeOff = "fade_off" FadeOff = "fade_off"
class SmartDimmer(SmartPlug): class IotDimmer(IotPlug):
r"""Representation of a TP-Link Smart Dimmer. r"""Representation of a TP-Link Smart Dimmer.
Dimmers work similarly to plugs, but provide also support for Dimmers work similarly to plugs, but provide also support for
@ -50,7 +51,7 @@ class SmartDimmer(SmartPlug):
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> dimmer = SmartDimmer("192.168.1.105") >>> dimmer = IotDimmer("192.168.1.105")
>>> asyncio.run(dimmer.turn_on()) >>> asyncio.run(dimmer.turn_on())
>>> dimmer.brightness >>> dimmer.brightness
25 25

View File

@ -1,14 +1,15 @@
"""Module for light strips (KL430).""" """Module for light strips (KL430)."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .deviceconfig import DeviceConfig from ..device_type import DeviceType
from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..deviceconfig import DeviceConfig
from .protocol import BaseProtocol from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
from .smartbulb import SmartBulb from ..protocol import BaseProtocol
from .smartdevice import DeviceType, SmartDeviceException, requires_update from .iotbulb import IotBulb
from .iotdevice import SmartDeviceException, requires_update
class SmartLightStrip(SmartBulb): class IotLightStrip(IotBulb):
"""Representation of a TP-Link Smart light strip. """Representation of a TP-Link Smart light strip.
Light strips work similarly to bulbs, but use a different service for controlling, Light strips work similarly to bulbs, but use a different service for controlling,
@ -17,7 +18,7 @@ class SmartLightStrip(SmartBulb):
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> strip = SmartLightStrip("127.0.0.1") >>> strip = IotLightStrip("127.0.0.1")
>>> asyncio.run(strip.update()) >>> asyncio.run(strip.update())
>>> print(strip.alias) >>> print(strip.alias)
KL430 pantry lightstrip KL430 pantry lightstrip

View File

@ -2,15 +2,16 @@
import logging import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from kasa.deviceconfig import DeviceConfig from ..device_type import DeviceType
from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage from ..deviceconfig import DeviceConfig
from kasa.protocol import BaseProtocol from ..protocol import BaseProtocol
from kasa.smartdevice import DeviceType, SmartDevice, requires_update from .iotdevice import IotDevice, requires_update
from .modules import Antitheft, Cloud, Schedule, Time, Usage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class SmartPlug(SmartDevice): class IotPlug(IotDevice):
r"""Representation of a TP-Link Smart Switch. r"""Representation of a TP-Link Smart Switch.
To initialize, you have to await :func:`update()` at least once. To initialize, you have to await :func:`update()` at least once.
@ -25,7 +26,7 @@ class SmartPlug(SmartDevice):
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> plug = SmartPlug("127.0.0.1") >>> plug = IotPlug("127.0.0.1")
>>> asyncio.run(plug.update()) >>> asyncio.run(plug.update())
>>> plug.alias >>> plug.alias
Kitchen Kitchen

View File

@ -4,19 +4,18 @@ from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, DefaultDict, Dict, Optional from typing import Any, DefaultDict, Dict, Optional
from kasa.smartdevice import ( from ..device_type import DeviceType
DeviceType, from ..deviceconfig import DeviceConfig
from ..exceptions import SmartDeviceException
from ..protocol import BaseProtocol
from .iotdevice import (
EmeterStatus, EmeterStatus,
SmartDevice, IotDevice,
SmartDeviceException,
merge, merge,
requires_update, requires_update,
) )
from kasa.smartplug import SmartPlug from .iotplug import IotPlug
from .deviceconfig import DeviceConfig
from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage
from .protocol import BaseProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -30,7 +29,7 @@ def merge_sums(dicts):
return total_dict return total_dict
class SmartStrip(SmartDevice): class IotStrip(IotDevice):
r"""Representation of a TP-Link Smart Power Strip. r"""Representation of a TP-Link Smart Power Strip.
A strip consists of the parent device and its children. A strip consists of the parent device and its children.
@ -49,7 +48,7 @@ class SmartStrip(SmartDevice):
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> strip = SmartStrip("127.0.0.1") >>> strip = IotStrip("127.0.0.1")
>>> asyncio.run(strip.update()) >>> asyncio.run(strip.update())
>>> strip.alias >>> strip.alias
TP-LINK_Power Strip_CF69 TP-LINK_Power Strip_CF69
@ -116,10 +115,10 @@ class SmartStrip(SmartDevice):
if not self.children: if not self.children:
children = self.sys_info["children"] children = self.sys_info["children"]
_LOGGER.debug("Initializing %s child sockets", len(children)) _LOGGER.debug("Initializing %s child sockets", len(children))
for child in children: self.children = [
self.children.append( IotStripPlug(self.host, parent=self, child_id=child["id"])
SmartStripPlug(self.host, parent=self, child_id=child["id"]) for child in children
) ]
if update_children and self.has_emeter: if update_children and self.has_emeter:
for plug in self.children: for plug in self.children:
@ -244,7 +243,7 @@ class SmartStrip(SmartDevice):
return EmeterStatus(emeter) return EmeterStatus(emeter)
class SmartStripPlug(SmartPlug): class IotStripPlug(IotPlug):
"""Representation of a single socket in a power strip. """Representation of a single socket in a power strip.
This allows you to use the sockets as they were SmartPlug objects. This allows you to use the sockets as they were SmartPlug objects.
@ -254,7 +253,7 @@ class SmartStripPlug(SmartPlug):
The plug inherits (most of) the system information from the parent. The plug inherits (most of) the system information from the parent.
""" """
def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: def __init__(self, host: str, parent: "IotStrip", child_id: str) -> None:
super().__init__(host) super().__init__(host)
self.parent = parent self.parent = parent

View File

@ -4,7 +4,7 @@ from .antitheft import Antitheft
from .cloud import Cloud from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .emeter import Emeter from .emeter import Emeter
from .module import Module from .module import IotModule
from .motion import Motion from .motion import Motion
from .rulemodule import Rule, RuleModule from .rulemodule import Rule, RuleModule
from .schedule import Schedule from .schedule import Schedule
@ -17,7 +17,7 @@ __all__ = [
"Cloud", "Cloud",
"Countdown", "Countdown",
"Emeter", "Emeter",
"Module", "IotModule",
"Motion", "Motion",
"Rule", "Rule",
"RuleModule", "RuleModule",

View File

@ -1,5 +1,5 @@
"""Implementation of the ambient light (LAS) module found in some dimmers.""" """Implementation of the ambient light (LAS) module found in some dimmers."""
from .module import Module from .module import IotModule
# TODO create tests and use the config reply there # TODO create tests and use the config reply there
# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450,
@ -11,7 +11,7 @@ from .module import Module
# {"name":"custom","adc":2400,"value":97}]}] # {"name":"custom","adc":2400,"value":97}]}]
class AmbientLight(Module): class AmbientLight(IotModule):
"""Implements ambient light controls for the motion sensor.""" """Implements ambient light controls for the motion sensor."""
def query(self): def query(self):

View File

@ -4,7 +4,7 @@ try:
except ImportError: except ImportError:
from pydantic import BaseModel from pydantic import BaseModel
from .module import Module from .module import IotModule
class CloudInfo(BaseModel): class CloudInfo(BaseModel):
@ -22,7 +22,7 @@ class CloudInfo(BaseModel):
username: str username: str
class Cloud(Module): class Cloud(IotModule):
"""Module implementing support for cloud services.""" """Module implementing support for cloud services."""
def query(self): def query(self):

View File

@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from ..emeterstatus import EmeterStatus from ...emeterstatus import EmeterStatus
from .usage import Usage from .usage import Usage

View File

@ -4,10 +4,10 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..exceptions import SmartDeviceException from ...exceptions import SmartDeviceException
if TYPE_CHECKING: if TYPE_CHECKING:
from kasa import SmartDevice from kasa.iot import IotDevice
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -24,15 +24,15 @@ def merge(d, u):
return d return d
class Module(ABC): class IotModule(ABC):
"""Base class implemention for all modules. """Base class implemention for all modules.
The base classes should implement `query` to return the query they want to be The base classes should implement `query` to return the query they want to be
executed during the regular update cycle. executed during the regular update cycle.
""" """
def __init__(self, device: "SmartDevice", module: str): def __init__(self, device: "IotDevice", module: str):
self._device: "SmartDevice" = device self._device = device
self._module = module self._module = module
@abstractmethod @abstractmethod

View File

@ -2,8 +2,8 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from ..exceptions import SmartDeviceException from ...exceptions import SmartDeviceException
from .module import Module from .module import IotModule
class Range(Enum): class Range(Enum):
@ -20,7 +20,7 @@ class Range(Enum):
# "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} # "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}}
class Motion(Module): class Motion(IotModule):
"""Implements the motion detection (PIR) module.""" """Implements the motion detection (PIR) module."""
def query(self): def query(self):

View File

@ -9,7 +9,7 @@ except ImportError:
from pydantic import BaseModel from pydantic import BaseModel
from .module import Module, merge from .module import IotModule, merge
class Action(Enum): class Action(Enum):
@ -55,7 +55,7 @@ class Rule(BaseModel):
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class RuleModule(Module): class RuleModule(IotModule):
"""Base class for rule-based modules, such as countdown and antitheft.""" """Base class for rule-based modules, such as countdown and antitheft."""
def query(self): def query(self):

View File

@ -1,11 +1,11 @@
"""Provides the current time and timezone information.""" """Provides the current time and timezone information."""
from datetime import datetime from datetime import datetime
from ..exceptions import SmartDeviceException from ...exceptions import SmartDeviceException
from .module import Module, merge from .module import IotModule, merge
class Time(Module): class Time(IotModule):
"""Implements the timezone settings.""" """Implements the timezone settings."""
def query(self): def query(self):

View File

@ -2,10 +2,10 @@
from datetime import datetime from datetime import datetime
from typing import Dict from typing import Dict
from .module import Module, merge from .module import IotModule, merge
class Usage(Module): class Usage(IotModule):
"""Baseclass for emeter/usage interfaces.""" """Baseclass for emeter/usage interfaces."""
def query(self): def query(self):

11
kasa/plug.py Normal file
View File

@ -0,0 +1,11 @@
"""Module for a TAPO Plug."""
import logging
from abc import ABC
from .device import Device
_LOGGER = logging.getLogger(__name__)
class Plug(Device, ABC):
"""Base class to represent a Plug."""

7
kasa/smart/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""Package for supporting tapo-branded and newer kasa devices."""
from .smartbulb import SmartBulb
from .smartchilddevice import SmartChildDevice
from .smartdevice import SmartDevice
from .smartplug import SmartPlug
__all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"]

View File

@ -1,9 +1,13 @@
"""Module for tapo-branded smart bulbs (L5**).""" """Module for tapo-branded smart bulbs (L5**)."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..bulb import Bulb
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import SmartDeviceException from ..exceptions import SmartDeviceException
from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange
from .tapodevice import TapoDevice from ..smartprotocol import SmartProtocol
from .smartdevice import SmartDevice
AVAILABLE_EFFECTS = { AVAILABLE_EFFECTS = {
"L1": "Party", "L1": "Party",
@ -11,12 +15,22 @@ AVAILABLE_EFFECTS = {
} }
class TapoBulb(TapoDevice, SmartBulb): class SmartBulb(SmartDevice, Bulb):
"""Representation of a TP-Link Tapo Bulb. """Representation of a TP-Link Tapo Bulb.
Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. Documentation TBD. See :class:`~kasa.iot.Bulb` for now.
""" """
def __init__(
self,
host: str,
*,
config: Optional[DeviceConfig] = None,
protocol: Optional[SmartProtocol] = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Bulb
@property @property
def is_color(self) -> bool: def is_color(self) -> bool:
"""Whether the bulb supports color changes.""" """Whether the bulb supports color changes."""
@ -257,6 +271,6 @@ class TapoBulb(TapoDevice, SmartBulb):
return info return info
@property @property
def presets(self) -> List[SmartBulbPreset]: def presets(self) -> List[BulbPreset]:
"""Return a list of available bulb setting presets.""" """Return a list of available bulb setting presets."""
return [] return []

View File

@ -4,10 +4,10 @@ from typing import Optional
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper
from .tapodevice import TapoDevice from .smartdevice import SmartDevice
class ChildDevice(TapoDevice): class SmartChildDevice(SmartDevice):
"""Presentation of a child device. """Presentation of a child device.
This wraps the protocol communications and sets internal data for the child. This wraps the protocol communications and sets internal data for the child.
@ -15,7 +15,7 @@ class ChildDevice(TapoDevice):
def __init__( def __init__(
self, self,
parent: TapoDevice, parent: SmartDevice,
child_id: str, child_id: str,
config: Optional[DeviceConfig] = None, config: Optional[DeviceConfig] = None,
protocol: Optional[SmartProtocol] = None, protocol: Optional[SmartProtocol] = None,

View File

@ -1,26 +1,25 @@
"""Module for a TAPO device.""" """Module for a SMART device."""
import base64 import base64
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast
from ..aestransport import AesTransport from ..aestransport import AesTransport
from ..device import Device, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationException, SmartDeviceException from ..exceptions import AuthenticationException, SmartDeviceException
from ..modules import Emeter
from ..smartdevice import SmartDevice, WifiNetwork
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from .childdevice import ChildDevice from .smartchilddevice import SmartChildDevice
class TapoDevice(SmartDevice): class SmartDevice(Device):
"""Base class to represent a TAPO device.""" """Base class to represent a SMART protocol based device."""
def __init__( def __init__(
self, self,
@ -36,39 +35,31 @@ class TapoDevice(SmartDevice):
self.protocol: SmartProtocol self.protocol: SmartProtocol
self._components_raw: Optional[Dict[str, Any]] = None self._components_raw: Optional[Dict[str, Any]] = None
self._components: Dict[str, int] = {} self._components: Dict[str, int] = {}
self._children: Dict[str, "ChildDevice"] = {} self._children: Dict[str, "SmartChildDevice"] = {}
self._energy: Dict[str, Any] = {} self._energy: Dict[str, Any] = {}
self._state_information: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {}
self._time: Dict[str, Any] = {}
async def _initialize_children(self): async def _initialize_children(self):
"""Initialize children for power strips.""" """Initialize children for power strips."""
children = self._last_update["child_info"]["child_device_list"] children = self._last_update["child_info"]["child_device_list"]
# TODO: Use the type information to construct children, # TODO: Use the type information to construct children,
# as hubs can also have them. # as hubs can also have them.
from .childdevice import ChildDevice from .smartchilddevice import SmartChildDevice
self._children = { self._children = {
child["device_id"]: ChildDevice(parent=self, child_id=child["device_id"]) child["device_id"]: SmartChildDevice(
parent=self, child_id=child["device_id"]
)
for child in children for child in children
} }
self._device_type = DeviceType.Strip self._device_type = DeviceType.Strip
@property @property
def children(self): def children(self) -> Sequence["SmartDevice"]:
"""Return list of children. """Return list of children."""
This is just to keep the existing SmartDevice API intact.
"""
return list(self._children.values()) return list(self._children.values())
@children.setter
def children(self, children):
"""Initialize from a list of children.
This is just to keep the existing SmartDevice API intact.
"""
self._children = {child["device_id"]: child for child in children}
async def update(self, update_children: bool = True): async def update(self, update_children: bool = True):
"""Update the device.""" """Update the device."""
if self.credentials is None and self.credentials_hash is None: if self.credentials is None and self.credentials_hash is None:
@ -133,7 +124,6 @@ class TapoDevice(SmartDevice):
"""Initialize modules based on component negotiation response.""" """Initialize modules based on component negotiation response."""
if "energy_monitoring" in self._components: if "energy_monitoring" in self._components:
self.emeter_type = "emeter" self.emeter_type = "emeter"
self.modules["emeter"] = Emeter(self, self.emeter_type)
@property @property
def sys_info(self) -> Dict[str, Any]: def sys_info(self) -> Dict[str, Any]:
@ -218,9 +208,9 @@ class TapoDevice(SmartDevice):
return self._last_update return self._last_update
async def _query_helper( async def _query_helper(
self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None self, method: str, params: Optional[Dict] = None, child_ids=None
) -> Any: ) -> Any:
res = await self.protocol.query({cmd: arg}) res = await self.protocol.query({method: params})
return res return res
@ -276,6 +266,13 @@ class TapoDevice(SmartDevice):
"""Return adjusted emeter information.""" """Return adjusted emeter information."""
return data if not data else data * scale return data if not data else data * scale
def _verify_emeter(self) -> None:
"""Raise an exception if there is no emeter."""
if not self.has_emeter:
raise SmartDeviceException("Device has no emeter")
if self.emeter_type not in self._last_update:
raise SmartDeviceException("update() required prior accessing emeter")
@property @property
def emeter_realtime(self) -> EmeterStatus: def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status.""" """Get the emeter status."""
@ -298,6 +295,17 @@ class TapoDevice(SmartDevice):
"""Get the emeter value for today.""" """Get the emeter value for today."""
return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000)
@property
def on_since(self) -> Optional[datetime]:
"""Return the time that the device was turned on or None if turned off."""
if (
not self._info.get("device_on")
or (on_time := self._info.get("on_time")) is None
):
return None
on_time = cast(float, on_time)
return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time)
async def wifi_scan(self) -> List[WifiNetwork]: async def wifi_scan(self) -> List[WifiNetwork]:
"""Scan for available wifi networks.""" """Scan for available wifi networks."""

View File

@ -1,17 +1,17 @@
"""Module for a TAPO Plug.""" """Module for a TAPO Plug."""
import logging import logging
from datetime import datetime, timedelta from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, cast
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..smartdevice import DeviceType from ..plug import Plug
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
from .tapodevice import TapoDevice from .smartdevice import SmartDevice
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class TapoPlug(TapoDevice): class SmartPlug(SmartDevice, Plug):
"""Class to represent a TAPO Plug.""" """Class to represent a TAPO Plug."""
def __init__( def __init__(
@ -35,11 +35,3 @@ class TapoPlug(TapoDevice):
"auto_off_remain_time": self._info.get("auto_off_remain_time"), "auto_off_remain_time": self._info.get("auto_off_remain_time"),
}, },
} }
@property
def on_since(self) -> Optional[datetime]:
"""Return the time that the device was turned on or None if turned off."""
if not self._info.get("device_on"):
return None
on_time = cast(float, self._info.get("on_time"))
return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time)

View File

@ -1,7 +0,0 @@
"""Package for supporting tapo-branded and newer kasa devices."""
from .childdevice import ChildDevice
from .tapobulb import TapoBulb
from .tapodevice import TapoDevice
from .tapoplug import TapoPlug
__all__ = ["TapoDevice", "TapoPlug", "TapoBulb", "ChildDevice"]

View File

@ -13,18 +13,14 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/
from kasa import ( from kasa import (
Credentials, Credentials,
Device,
DeviceConfig, DeviceConfig,
Discover, Discover,
SmartBulb,
SmartDevice,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartProtocol, SmartProtocol,
SmartStrip,
) )
from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip
from kasa.protocol import BaseTransport from kasa.protocol import BaseTransport
from kasa.tapo import TapoBulb, TapoPlug from kasa.smart import SmartBulb, SmartPlug
from kasa.xortransport import XorEncryption from kasa.xortransport import XorEncryption
from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_iot import FakeIotProtocol
@ -350,37 +346,37 @@ def device_for_file(model, protocol):
if protocol == "SMART": if protocol == "SMART":
for d in PLUGS_SMART: for d in PLUGS_SMART:
if d in model: if d in model:
return TapoPlug return SmartPlug
for d in BULBS_SMART: for d in BULBS_SMART:
if d in model: if d in model:
return TapoBulb return SmartBulb
for d in DIMMERS_SMART: for d in DIMMERS_SMART:
if d in model: if d in model:
return TapoBulb return SmartBulb
for d in STRIPS_SMART: for d in STRIPS_SMART:
if d in model: if d in model:
return TapoPlug return SmartPlug
else: else:
for d in STRIPS_IOT: for d in STRIPS_IOT:
if d in model: if d in model:
return SmartStrip return IotStrip
for d in PLUGS_IOT: for d in PLUGS_IOT:
if d in model: if d in model:
return SmartPlug return IotPlug
# Light strips are recognized also as bulbs, so this has to go first # Light strips are recognized also as bulbs, so this has to go first
for d in BULBS_IOT_LIGHT_STRIP: for d in BULBS_IOT_LIGHT_STRIP:
if d in model: if d in model:
return SmartLightStrip return IotLightStrip
for d in BULBS_IOT: for d in BULBS_IOT:
if d in model: if d in model:
return SmartBulb return IotBulb
for d in DIMMERS_IOT: for d in DIMMERS_IOT:
if d in model: if d in model:
return SmartDimmer return IotDimmer
raise Exception("Unable to find type for %s", model) raise Exception("Unable to find type for %s", model)
@ -446,11 +442,11 @@ async def dev(request):
IP_MODEL_CACHE[ip] = model = d.model IP_MODEL_CACHE[ip] = model = d.model
if model not in file: if model not in file:
pytest.skip(f"skipping file {file}") pytest.skip(f"skipping file {file}")
dev: SmartDevice = ( dev: Device = (
d if d else await _discover_update_and_close(ip, username, password) d if d else await _discover_update_and_close(ip, username, password)
) )
else: else:
dev: SmartDevice = await get_device_for_file(file, protocol) dev: Device = await get_device_for_file(file, protocol)
yield dev yield dev

View File

@ -7,7 +7,8 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException
from kasa.iot import IotBulb
from .conftest import ( from .conftest import (
bulb, bulb,
@ -27,7 +28,7 @@ from .test_smartdevice import SYSINFO_SCHEMA
@bulb @bulb
async def test_bulb_sysinfo(dev: SmartBulb): async def test_bulb_sysinfo(dev: Bulb):
assert dev.sys_info is not None assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info) SYSINFO_SCHEMA_BULB(dev.sys_info)
@ -40,7 +41,7 @@ async def test_bulb_sysinfo(dev: SmartBulb):
@bulb @bulb
async def test_state_attributes(dev: SmartBulb): async def test_state_attributes(dev: Bulb):
assert "Brightness" in dev.state_information assert "Brightness" in dev.state_information
assert dev.state_information["Brightness"] == dev.brightness assert dev.state_information["Brightness"] == dev.brightness
@ -49,7 +50,7 @@ async def test_state_attributes(dev: SmartBulb):
@bulb_iot @bulb_iot
async def test_light_state_without_update(dev: SmartBulb, monkeypatch): async def test_light_state_without_update(dev: IotBulb, monkeypatch):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
monkeypatch.setitem( monkeypatch.setitem(
dev._last_update["system"]["get_sysinfo"], "light_state", None dev._last_update["system"]["get_sysinfo"], "light_state", None
@ -58,13 +59,13 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch):
@bulb_iot @bulb_iot
async def test_get_light_state(dev: SmartBulb): async def test_get_light_state(dev: IotBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state()) LIGHT_STATE_SCHEMA(await dev.get_light_state())
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev: SmartBulb, turn_on): async def test_hsv(dev: Bulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_color assert dev.is_color
@ -83,8 +84,8 @@ async def test_hsv(dev: SmartBulb, turn_on):
@color_bulb_iot @color_bulb_iot
async def test_set_hsv_transition(dev: SmartBulb, mocker): async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_hsv(10, 10, 100, transition=1000) await dev.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with( set_light_state.assert_called_with(
@ -95,31 +96,31 @@ async def test_set_hsv_transition(dev: SmartBulb, mocker):
@color_bulb @color_bulb
@turn_on @turn_on
async def test_invalid_hsv(dev: SmartBulb, turn_on): async def test_invalid_hsv(dev: Bulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_color assert dev.is_color
for invalid_hue in [-1, 361, 0.5]: for invalid_hue in [-1, 361, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_hsv(invalid_hue, 0, 0) await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type]
for invalid_saturation in [-1, 101, 0.5]: for invalid_saturation in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_hsv(0, invalid_saturation, 0) await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type]
for invalid_brightness in [-1, 101, 0.5]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_hsv(0, 0, invalid_brightness) await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type]
@color_bulb @color_bulb
async def test_color_state_information(dev: SmartBulb): async def test_color_state_information(dev: Bulb):
assert "HSV" in dev.state_information assert "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv assert dev.state_information["HSV"] == dev.hsv
@non_color_bulb @non_color_bulb
async def test_hsv_on_non_color(dev: SmartBulb): async def test_hsv_on_non_color(dev: Bulb):
assert not dev.is_color assert not dev.is_color
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
@ -129,7 +130,7 @@ async def test_hsv_on_non_color(dev: SmartBulb):
@variable_temp @variable_temp
async def test_variable_temp_state_information(dev: SmartBulb): async def test_variable_temp_state_information(dev: Bulb):
assert "Color temperature" in dev.state_information assert "Color temperature" in dev.state_information
assert dev.state_information["Color temperature"] == dev.color_temp assert dev.state_information["Color temperature"] == dev.color_temp
@ -141,7 +142,7 @@ async def test_variable_temp_state_information(dev: SmartBulb):
@variable_temp @variable_temp
@turn_on @turn_on
async def test_try_set_colortemp(dev: SmartBulb, turn_on): async def test_try_set_colortemp(dev: Bulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
await dev.update() await dev.update()
@ -149,15 +150,15 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on):
@variable_temp_iot @variable_temp_iot
async def test_set_color_temp_transition(dev: SmartBulb, mocker): async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_color_temp(2700, transition=100) await dev.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@variable_temp_iot @variable_temp_iot
async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
assert dev.valid_temperature_range == (2700, 5000) assert dev.valid_temperature_range == (2700, 5000)
@ -165,7 +166,7 @@ async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog):
@variable_temp @variable_temp
async def test_out_of_range_temperature(dev: SmartBulb): async def test_out_of_range_temperature(dev: Bulb):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_color_temp(1000) await dev.set_color_temp(1000)
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -173,7 +174,7 @@ async def test_out_of_range_temperature(dev: SmartBulb):
@non_variable_temp @non_variable_temp
async def test_non_variable_temp(dev: SmartBulb): async def test_non_variable_temp(dev: Bulb):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
@ -186,7 +187,7 @@ async def test_non_variable_temp(dev: SmartBulb):
@dimmable @dimmable
@turn_on @turn_on
async def test_dimmable_brightness(dev: SmartBulb, turn_on): async def test_dimmable_brightness(dev: Bulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_dimmable assert dev.is_dimmable
@ -199,12 +200,12 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on):
assert dev.brightness == 10 assert dev.brightness == 10
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_brightness("foo") await dev.set_brightness("foo") # type: ignore[arg-type]
@bulb_iot @bulb_iot
async def test_turn_on_transition(dev: SmartBulb, mocker): async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.turn_on(transition=1000) await dev.turn_on(transition=1000)
set_light_state.assert_called_with({"on_off": 1}, transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000)
@ -215,15 +216,15 @@ async def test_turn_on_transition(dev: SmartBulb, mocker):
@bulb_iot @bulb_iot
async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_brightness(10, transition=1000) await dev.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10}, transition=1000) set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@dimmable @dimmable
async def test_invalid_brightness(dev: SmartBulb): async def test_invalid_brightness(dev: Bulb):
assert dev.is_dimmable assert dev.is_dimmable
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -234,7 +235,7 @@ async def test_invalid_brightness(dev: SmartBulb):
@non_dimmable @non_dimmable
async def test_non_dimmable(dev: SmartBulb): async def test_non_dimmable(dev: Bulb):
assert not dev.is_dimmable assert not dev.is_dimmable
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
@ -245,9 +246,9 @@ async def test_non_dimmable(dev: SmartBulb):
@bulb_iot @bulb_iot
async def test_ignore_default_not_set_without_color_mode_change_turn_on( async def test_ignore_default_not_set_without_color_mode_change_turn_on(
dev: SmartBulb, mocker dev: IotBulb, mocker
): ):
query_helper = mocker.patch("kasa.SmartBulb._query_helper") query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
# When turning back without settings, ignore default to restore the state # When turning back without settings, ignore default to restore the state
await dev.turn_on() await dev.turn_on()
args, kwargs = query_helper.call_args_list[0] args, kwargs = query_helper.call_args_list[0]
@ -259,7 +260,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on(
@bulb_iot @bulb_iot
async def test_list_presets(dev: SmartBulb): async def test_list_presets(dev: IotBulb):
presets = dev.presets presets = dev.presets
assert len(presets) == len(dev.sys_info["preferred_state"]) assert len(presets) == len(dev.sys_info["preferred_state"])
@ -272,7 +273,7 @@ async def test_list_presets(dev: SmartBulb):
@bulb_iot @bulb_iot
async def test_modify_preset(dev: SmartBulb, mocker): async def test_modify_preset(dev: IotBulb, mocker):
"""Verify that modifying preset calls the and exceptions are raised properly.""" """Verify that modifying preset calls the and exceptions are raised properly."""
if not dev.presets: if not dev.presets:
pytest.skip("Some strips do not support presets") pytest.skip("Some strips do not support presets")
@ -284,7 +285,7 @@ async def test_modify_preset(dev: SmartBulb, mocker):
"saturation": 0, "saturation": 0,
"color_temp": 0, "color_temp": 0,
} }
preset = SmartBulbPreset(**data) preset = BulbPreset(**data)
assert preset.index == 0 assert preset.index == 0
assert preset.brightness == 10 assert preset.brightness == 10
@ -297,7 +298,7 @@ async def test_modify_preset(dev: SmartBulb, mocker):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
await dev.save_preset( await dev.save_preset(
SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0)
) )
@ -306,21 +307,21 @@ async def test_modify_preset(dev: SmartBulb, mocker):
("preset", "payload"), ("preset", "payload"),
[ [
( (
SmartBulbPreset(index=0, hue=0, brightness=1, saturation=0), BulbPreset(index=0, hue=0, brightness=1, saturation=0),
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, {"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
), ),
( (
SmartBulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0),
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
), ),
], ],
) )
async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker):
"""Test that modify preset payloads ignore none values.""" """Test that modify preset payloads ignore none values."""
if not dev.presets: if not dev.presets:
pytest.skip("Some strips do not support presets") pytest.skip("Some strips do not support presets")
query_helper = mocker.patch("kasa.SmartBulb._query_helper") query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
await dev.save_preset(preset) await dev.save_preset(preset)
query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload)

View File

@ -3,8 +3,8 @@ import sys
import pytest import pytest
from kasa.smart.smartchilddevice import SmartChildDevice
from kasa.smartprotocol import _ChildProtocolWrapper from kasa.smartprotocol import _ChildProtocolWrapper
from kasa.tapo.childdevice import ChildDevice
from .conftest import strip_smart from .conftest import strip_smart
@ -42,7 +42,7 @@ async def test_childdevice_update(dev, dummy_protocol, mocker):
sys.version_info < (3, 11), sys.version_info < (3, 11),
reason="exceptiongroup requires python3.11+", reason="exceptiongroup requires python3.11+",
) )
async def test_childdevice_properties(dev: ChildDevice): async def test_childdevice_properties(dev: SmartChildDevice):
"""Check that accessing childdevice properties do not raise exceptions.""" """Check that accessing childdevice properties do not raise exceptions."""
assert len(dev.children) > 0 assert len(dev.children) > 0

View File

@ -7,8 +7,8 @@ from asyncclick.testing import CliRunner
from kasa import ( from kasa import (
AuthenticationException, AuthenticationException,
Device,
EmeterStatus, EmeterStatus,
SmartDevice,
SmartDeviceException, SmartDeviceException,
UnsupportedDeviceException, UnsupportedDeviceException,
) )
@ -27,6 +27,7 @@ from kasa.cli import (
wifi, wifi,
) )
from kasa.discover import Discover, DiscoveryResult from kasa.discover import Discover, DiscoveryResult
from kasa.iot import IotDevice
from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on
@ -107,9 +108,9 @@ async def test_alias(dev):
async def test_raw_command(dev, mocker): async def test_raw_command(dev, mocker):
runner = CliRunner() runner = CliRunner()
update = mocker.patch.object(dev, "update") update = mocker.patch.object(dev, "update")
from kasa.tapo import TapoDevice from kasa.smart import SmartDevice
if isinstance(dev, TapoDevice): if isinstance(dev, SmartDevice):
params = ["na", "get_device_info"] params = ["na", "get_device_info"]
else: else:
params = ["system", "get_sysinfo"] params = ["system", "get_sysinfo"]
@ -216,7 +217,7 @@ async def test_update_credentials(dev):
) )
async def test_emeter(dev: SmartDevice, mocker): async def test_emeter(dev: Device, mocker):
runner = CliRunner() runner = CliRunner()
res = await runner.invoke(emeter, obj=dev) res = await runner.invoke(emeter, obj=dev)
@ -245,16 +246,24 @@ async def test_emeter(dev: SmartDevice, mocker):
assert "Voltage: 122.066 V" in res.output assert "Voltage: 122.066 V" in res.output
assert realtime_emeter.call_count == 2 assert realtime_emeter.call_count == 2
monthly = mocker.patch.object(dev, "get_emeter_monthly") if isinstance(dev, IotDevice):
monthly.return_value = {1: 1234} monthly = mocker.patch.object(dev, "get_emeter_monthly")
monthly.return_value = {1: 1234}
res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) res = await runner.invoke(emeter, ["--year", "1900"], obj=dev)
if not isinstance(dev, IotDevice):
assert "Device has no historical statistics" in res.output
return
assert "For year" in res.output assert "For year" in res.output
assert "1, 1234" in res.output assert "1, 1234" in res.output
monthly.assert_called_with(year=1900) monthly.assert_called_with(year=1900)
daily = mocker.patch.object(dev, "get_emeter_daily") if isinstance(dev, IotDevice):
daily.return_value = {1: 1234} daily = mocker.patch.object(dev, "get_emeter_daily")
daily.return_value = {1: 1234}
res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev)
if not isinstance(dev, IotDevice):
assert "Device has no historical statistics" in res.output
return
assert "For month" in res.output assert "For month" in res.output
assert "1, 1234" in res.output assert "1, 1234" in res.output
daily.assert_called_with(year=1900, month=12) daily.assert_called_with(year=1900, month=12)
@ -279,7 +288,7 @@ async def test_brightness(dev):
@device_iot @device_iot
async def test_json_output(dev: SmartDevice, mocker): async def test_json_output(dev: Device, mocker):
"""Test that the json output produces correct output.""" """Test that the json output produces correct output."""
mocker.patch("kasa.Discover.discover", return_value=[dev]) mocker.patch("kasa.Discover.discover", return_value=[dev])
runner = CliRunner() runner = CliRunner()
@ -292,10 +301,10 @@ async def test_json_output(dev: SmartDevice, mocker):
async def test_credentials(discovery_mock, mocker): async def test_credentials(discovery_mock, mocker):
"""Test credentials are passed correctly from cli to device.""" """Test credentials are passed correctly from cli to device."""
# Patch state to echo username and password # Patch state to echo username and password
pass_dev = click.make_pass_decorator(SmartDevice) pass_dev = click.make_pass_decorator(Device)
@pass_dev @pass_dev
async def _state(dev: SmartDevice): async def _state(dev: Device):
if dev.credentials: if dev.credentials:
click.echo( click.echo(
f"Username:{dev.credentials.username} Password:{dev.credentials.password}" f"Username:{dev.credentials.username} Password:{dev.credentials.password}"
@ -513,10 +522,10 @@ async def test_type_param(device_type, mocker):
runner = CliRunner() runner = CliRunner()
result_device = FileNotFoundError result_device = FileNotFoundError
pass_dev = click.make_pass_decorator(SmartDevice) pass_dev = click.make_pass_decorator(Device)
@pass_dev @pass_dev
async def _state(dev: SmartDevice): async def _state(dev: Device):
nonlocal result_device nonlocal result_device
result_device = dev result_device = dev

View File

@ -6,8 +6,8 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
from kasa import ( from kasa import (
Credentials, Credentials,
Device,
Discover, Discover,
SmartDevice,
SmartDeviceException, SmartDeviceException,
) )
from kasa.device_factory import connect, get_protocol from kasa.device_factory import connect, get_protocol
@ -83,7 +83,7 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port):
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
dev = await connect(config=config) dev = await connect(config=config)
assert issubclass(dev.__class__, SmartDevice) assert issubclass(dev.__class__, Device)
assert dev.port == custom_port or dev.port == default_port assert dev.port == custom_port or dev.port == default_port

View File

@ -1,4 +1,4 @@
from kasa.smartdevice import DeviceType from kasa.device_type import DeviceType
async def test_device_type_from_value(): async def test_device_type_from_value():

View File

@ -1,6 +1,6 @@
import pytest import pytest
from kasa import SmartDimmer from kasa.iot import IotDimmer
from .conftest import dimmer, handle_turn_on, turn_on from .conftest import dimmer, handle_turn_on, turn_on
@ -23,7 +23,7 @@ async def test_set_brightness(dev, turn_on):
@turn_on @turn_on
async def test_set_brightness_transition(dev, turn_on, mocker): async def test_set_brightness_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
query_helper = mocker.spy(SmartDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
await dev.set_brightness(99, transition=1000) await dev.set_brightness(99, transition=1000)
@ -53,7 +53,7 @@ async def test_set_brightness_invalid(dev):
@dimmer @dimmer
async def test_turn_on_transition(dev, mocker): async def test_turn_on_transition(dev, mocker):
query_helper = mocker.spy(SmartDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
original_brightness = dev.brightness original_brightness = dev.brightness
await dev.turn_on(transition=1000) await dev.turn_on(transition=1000)
@ -71,7 +71,7 @@ async def test_turn_on_transition(dev, mocker):
@dimmer @dimmer
async def test_turn_off_transition(dev, mocker): async def test_turn_off_transition(dev, mocker):
await handle_turn_on(dev, True) await handle_turn_on(dev, True)
query_helper = mocker.spy(SmartDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
original_brightness = dev.brightness original_brightness = dev.brightness
await dev.turn_off(transition=1000) await dev.turn_off(transition=1000)
@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker):
@turn_on @turn_on
async def test_set_dimmer_transition(dev, turn_on, mocker): async def test_set_dimmer_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
query_helper = mocker.spy(SmartDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
await dev.set_dimmer_transition(99, 1000) await dev.set_dimmer_transition(99, 1000)
@ -109,7 +109,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker):
async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
original_brightness = dev.brightness original_brightness = dev.brightness
query_helper = mocker.spy(SmartDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
await dev.set_dimmer_transition(0, 1000) await dev.set_dimmer_transition(0, 1000)

View File

@ -10,9 +10,9 @@ from async_timeout import timeout as asyncio_timeout
from kasa import ( from kasa import (
Credentials, Credentials,
Device,
DeviceType, DeviceType,
Discover, Discover,
SmartDevice,
SmartDeviceException, SmartDeviceException,
) )
from kasa.deviceconfig import ( from kasa.deviceconfig import (
@ -21,6 +21,7 @@ from kasa.deviceconfig import (
) )
from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps
from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from kasa.exceptions import AuthenticationException, UnsupportedDeviceException
from kasa.iot import IotDevice
from kasa.xortransport import XorEncryption from kasa.xortransport import XorEncryption
from .conftest import ( from .conftest import (
@ -55,14 +56,14 @@ UNSUPPORTED = {
@plug @plug
async def test_type_detection_plug(dev: SmartDevice): async def test_type_detection_plug(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_plug assert d.is_plug
assert d.device_type == DeviceType.Plug assert d.device_type == DeviceType.Plug
@bulb_iot @bulb_iot
async def test_type_detection_bulb(dev: SmartDevice): async def test_type_detection_bulb(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
# TODO: light_strip is a special case for now to force bulb tests on it # TODO: light_strip is a special case for now to force bulb tests on it
if not d.is_light_strip: if not d.is_light_strip:
@ -71,21 +72,21 @@ async def test_type_detection_bulb(dev: SmartDevice):
@strip_iot @strip_iot
async def test_type_detection_strip(dev: SmartDevice): async def test_type_detection_strip(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_strip assert d.is_strip
assert d.device_type == DeviceType.Strip assert d.device_type == DeviceType.Strip
@dimmer @dimmer
async def test_type_detection_dimmer(dev: SmartDevice): async def test_type_detection_dimmer(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_dimmer assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer assert d.device_type == DeviceType.Dimmer
@lightstrip @lightstrip
async def test_type_detection_lightstrip(dev: SmartDevice): async def test_type_detection_lightstrip(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_light_strip assert d.is_light_strip
assert d.device_type == DeviceType.LightStrip assert d.device_type == DeviceType.LightStrip
@ -111,7 +112,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker):
x = await Discover.discover_single( x = await Discover.discover_single(
host, port=custom_port, credentials=Credentials() host, port=custom_port, credentials=Credentials()
) )
assert issubclass(x.__class__, SmartDevice) assert issubclass(x.__class__, Device)
assert x._discovery_info is not None assert x._discovery_info is not None
assert x.port == custom_port or x.port == discovery_mock.default_port assert x.port == custom_port or x.port == discovery_mock.default_port
assert update_mock.call_count == 0 assert update_mock.call_count == 0
@ -144,7 +145,7 @@ async def test_discover_single_hostname(discovery_mock, mocker):
update_mock = mocker.patch.object(device_class, "update") update_mock = mocker.patch.object(device_class, "update")
x = await Discover.discover_single(host, credentials=Credentials()) x = await Discover.discover_single(host, credentials=Credentials())
assert issubclass(x.__class__, SmartDevice) assert issubclass(x.__class__, Device)
assert x._discovery_info is not None assert x._discovery_info is not None
assert x.host == host assert x.host == host
assert update_mock.call_count == 0 assert update_mock.call_count == 0
@ -232,7 +233,7 @@ async def test_discover_datagram_received(mocker, discovery_data):
# Check that unsupported device is 1 # Check that unsupported device is 1
assert len(proto.unsupported_device_exceptions) == 1 assert len(proto.unsupported_device_exceptions) == 1
dev = proto.discovered_devices[addr] dev = proto.discovered_devices[addr]
assert issubclass(dev.__class__, SmartDevice) assert issubclass(dev.__class__, Device)
assert dev.host == addr assert dev.host == addr
@ -298,7 +299,7 @@ async def test_discover_single_authentication(discovery_mock, mocker):
@new_discovery @new_discovery
async def test_device_update_from_new_discovery_info(discovery_data): async def test_device_update_from_new_discovery_info(discovery_data):
device = SmartDevice("127.0.0.7") device = IotDevice("127.0.0.7")
discover_info = DiscoveryResult(**discovery_data["result"]) discover_info = DiscoveryResult(**discovery_data["result"])
discover_dump = discover_info.get_dict() discover_dump = discover_info.get_dict()
discover_dump["alias"] = "foobar" discover_dump["alias"] = "foobar"
@ -323,7 +324,7 @@ async def test_discover_single_http_client(discovery_mock, mocker):
http_client = aiohttp.ClientSession() http_client = aiohttp.ClientSession()
x: SmartDevice = await Discover.discover_single(host) x: Device = await Discover.discover_single(host)
assert x.config.uses_http == (discovery_mock.default_port == 80) assert x.config.uses_http == (discovery_mock.default_port == 80)
@ -341,7 +342,7 @@ async def test_discover_http_client(discovery_mock, mocker):
http_client = aiohttp.ClientSession() http_client = aiohttp.ClientSession()
devices = await Discover.discover(discovery_timeout=0) devices = await Discover.discover(discovery_timeout=0)
x: SmartDevice = devices[host] x: Device = devices[host]
assert x.config.uses_http == (discovery_mock.default_port == 80) assert x.config.uses_http == (discovery_mock.default_port == 80)
if discovery_mock.default_port == 80: if discovery_mock.default_port == 80:

View File

@ -11,7 +11,8 @@ from voluptuous import (
) )
from kasa import EmeterStatus, SmartDeviceException from kasa import EmeterStatus, SmartDeviceException
from kasa.modules.emeter import Emeter from kasa.iot import IotDevice
from kasa.iot.modules.emeter import Emeter
from .conftest import has_emeter, has_emeter_iot, no_emeter from .conftest import has_emeter, has_emeter_iot, no_emeter
@ -39,12 +40,15 @@ async def test_no_emeter(dev):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
await dev.get_emeter_realtime() await dev.get_emeter_realtime()
with pytest.raises(SmartDeviceException): # Only iot devices support the historical stats so other
await dev.get_emeter_daily() # devices will not implement the methods below
with pytest.raises(SmartDeviceException): if isinstance(dev, IotDevice):
await dev.get_emeter_monthly() with pytest.raises(SmartDeviceException):
with pytest.raises(SmartDeviceException): await dev.get_emeter_daily()
await dev.erase_emeter_stats() with pytest.raises(SmartDeviceException):
await dev.get_emeter_monthly()
with pytest.raises(SmartDeviceException):
await dev.erase_emeter_stats()
@has_emeter @has_emeter
@ -121,7 +125,7 @@ async def test_erase_emeter_stats(dev):
await dev.erase_emeter() await dev.erase_emeter()
@has_emeter @has_emeter_iot
async def test_current_consumption(dev): async def test_current_consumption(dev):
if dev.has_emeter: if dev.has_emeter:
x = await dev.current_consumption() x = await dev.current_consumption()

View File

@ -1,27 +1,28 @@
import pytest import pytest
from kasa import DeviceType, SmartLightStrip from kasa import DeviceType
from kasa.exceptions import SmartDeviceException from kasa.exceptions import SmartDeviceException
from kasa.iot import IotLightStrip
from .conftest import lightstrip from .conftest import lightstrip
@lightstrip @lightstrip
async def test_lightstrip_length(dev: SmartLightStrip): async def test_lightstrip_length(dev: IotLightStrip):
assert dev.is_light_strip assert dev.is_light_strip
assert dev.device_type == DeviceType.LightStrip assert dev.device_type == DeviceType.LightStrip
assert dev.length == dev.sys_info["length"] assert dev.length == dev.sys_info["length"]
@lightstrip @lightstrip
async def test_lightstrip_effect(dev: SmartLightStrip): async def test_lightstrip_effect(dev: IotLightStrip):
assert isinstance(dev.effect, dict) assert isinstance(dev.effect, dict)
for k in ["brightness", "custom", "enable", "id", "name"]: for k in ["brightness", "custom", "enable", "id", "name"]:
assert k in dev.effect assert k in dev.effect
@lightstrip @lightstrip
async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
await dev.set_effect("Not real") await dev.set_effect("Not real")
@ -33,9 +34,9 @@ async def test_effects_lightstrip_set_effect(dev: SmartLightStrip):
@lightstrip @lightstrip
@pytest.mark.parametrize("brightness", [100, 50]) @pytest.mark.parametrize("brightness", [100, 50])
async def test_effects_lightstrip_set_effect_brightness( async def test_effects_lightstrip_set_effect_brightness(
dev: SmartLightStrip, brightness, mocker dev: IotLightStrip, brightness, mocker
): ):
query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper")
# test that default brightness works (100 for candy cane) # test that default brightness works (100 for candy cane)
if brightness == 100: if brightness == 100:
@ -51,9 +52,9 @@ async def test_effects_lightstrip_set_effect_brightness(
@lightstrip @lightstrip
@pytest.mark.parametrize("transition", [500, 1000]) @pytest.mark.parametrize("transition", [500, 1000])
async def test_effects_lightstrip_set_effect_transition( async def test_effects_lightstrip_set_effect_transition(
dev: SmartLightStrip, transition, mocker dev: IotLightStrip, transition, mocker
): ):
query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper")
# test that default (500 for candy cane) transition works # test that default (500 for candy cane) transition works
if transition == 500: if transition == 500:
@ -67,6 +68,6 @@ async def test_effects_lightstrip_set_effect_transition(
@lightstrip @lightstrip
async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): async def test_effects_lightstrip_has_effects(dev: IotLightStrip):
assert dev.has_effects is True assert dev.has_effects is True
assert dev.effect_list assert dev.effect_list

View File

@ -8,54 +8,54 @@ from kasa.tests.conftest import get_device_for_file
def test_bulb_examples(mocker): def test_bulb_examples(mocker):
"""Use KL130 (bulb with all features) to test the doctests.""" """Use KL130 (bulb with all features) to test the doctests."""
p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT"))
mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p)
mocker.patch("kasa.smartbulb.SmartBulb.update") mocker.patch("kasa.iot.iotbulb.IotBulb.update")
res = xdoctest.doctest_module("kasa.smartbulb", "all") res = xdoctest.doctest_module("kasa.iot.iotbulb", "all")
assert not res["failed"] assert not res["failed"]
def test_smartdevice_examples(mocker): def test_smartdevice_examples(mocker):
"""Use HS110 for emeter examples.""" """Use HS110 for emeter examples."""
p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT"))
mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p)
mocker.patch("kasa.smartdevice.SmartDevice.update") mocker.patch("kasa.iot.iotdevice.IotDevice.update")
res = xdoctest.doctest_module("kasa.smartdevice", "all") res = xdoctest.doctest_module("kasa.iot.iotdevice", "all")
assert not res["failed"] assert not res["failed"]
def test_plug_examples(mocker): def test_plug_examples(mocker):
"""Test plug examples.""" """Test plug examples."""
p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT"))
mocker.patch("kasa.smartplug.SmartPlug", return_value=p) mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p)
mocker.patch("kasa.smartplug.SmartPlug.update") mocker.patch("kasa.iot.iotplug.IotPlug.update")
res = xdoctest.doctest_module("kasa.smartplug", "all") res = xdoctest.doctest_module("kasa.iot.iotplug", "all")
assert not res["failed"] assert not res["failed"]
def test_strip_examples(mocker): def test_strip_examples(mocker):
"""Test strip examples.""" """Test strip examples."""
p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT"))
mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p)
mocker.patch("kasa.smartstrip.SmartStrip.update") mocker.patch("kasa.iot.iotstrip.IotStrip.update")
res = xdoctest.doctest_module("kasa.smartstrip", "all") res = xdoctest.doctest_module("kasa.iot.iotstrip", "all")
assert not res["failed"] assert not res["failed"]
def test_dimmer_examples(mocker): def test_dimmer_examples(mocker):
"""Test dimmer examples.""" """Test dimmer examples."""
p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT"))
mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p)
mocker.patch("kasa.smartdimmer.SmartDimmer.update") mocker.patch("kasa.iot.iotdimmer.IotDimmer.update")
res = xdoctest.doctest_module("kasa.smartdimmer", "all") res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all")
assert not res["failed"] assert not res["failed"]
def test_lightstrip_examples(mocker): def test_lightstrip_examples(mocker):
"""Test lightstrip examples.""" """Test lightstrip examples."""
p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT"))
mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p)
mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update")
res = xdoctest.doctest_module("kasa.smartlightstrip", "all") res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all")
assert not res["failed"] assert not res["failed"]

View File

@ -1,5 +1,8 @@
import importlib
import inspect import inspect
import pkgutil
import re import re
import sys
from datetime import datetime from datetime import datetime
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@ -17,20 +20,33 @@ from voluptuous import (
) )
import kasa import kasa
from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException from kasa import Credentials, Device, DeviceConfig, SmartDeviceException
from kasa.iot import IotDevice
from kasa.smart import SmartChildDevice, SmartDevice
from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on
from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_iot import FakeIotProtocol
# List of all SmartXXX classes including the SmartDevice base class
smart_device_classes = [ def _get_subclasses(of_class):
dc package = sys.modules["kasa"]
for (mn, dc) in inspect.getmembers( subclasses = set()
kasa, for _, modname, _ in pkgutil.iter_modules(package.__path__):
lambda member: inspect.isclass(member) importlib.import_module("." + modname, package="kasa")
and (member == SmartDevice or issubclass(member, SmartDevice)), module = sys.modules["kasa." + modname]
) for name, obj in inspect.getmembers(module):
] if (
inspect.isclass(obj)
and issubclass(obj, of_class)
and module.__package__ != "kasa"
):
subclasses.add((module.__package__ + "." + name, obj))
return subclasses
device_classes = pytest.mark.parametrize(
"device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0]
)
@device_iot @device_iot
@ -220,21 +236,26 @@ async def test_estimated_response_sizes(dev):
assert mod.estimated_query_response_size > 0 assert mod.estimated_query_response_size > 0
@pytest.mark.parametrize("device_class", smart_device_classes) @device_classes
def test_device_class_ctors(device_class): async def test_device_class_ctors(device_class_name_obj):
"""Make sure constructor api not broken for new and existing SmartDevices.""" """Make sure constructor api not broken for new and existing SmartDevices."""
host = "127.0.0.2" host = "127.0.0.2"
port = 1234 port = 1234
credentials = Credentials("foo", "bar") credentials = Credentials("foo", "bar")
config = DeviceConfig(host, port_override=port, credentials=credentials) config = DeviceConfig(host, port_override=port, credentials=credentials)
dev = device_class(host, config=config) klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice):
parent = SmartDevice(host, config=config)
dev = klass(parent, 1)
else:
dev = klass(host, config=config)
assert dev.host == host assert dev.host == host
assert dev.port == port assert dev.port == port
assert dev.credentials == credentials assert dev.credentials == credentials
@device_iot @device_iot
async def test_modules_preserved(dev: SmartDevice): async def test_modules_preserved(dev: IotDevice):
"""Make modules that are not being updated are preserved between updates.""" """Make modules that are not being updated are preserved between updates."""
dev._last_update["some_module_not_being_updated"] = "should_be_kept" dev._last_update["some_module_not_being_updated"] = "should_be_kept"
await dev.update() await dev.update()
@ -244,6 +265,8 @@ async def test_modules_preserved(dev: SmartDevice):
async def test_create_smart_device_with_timeout(): async def test_create_smart_device_with_timeout():
"""Make sure timeout is passed to the protocol.""" """Make sure timeout is passed to the protocol."""
host = "127.0.0.1" host = "127.0.0.1"
dev = IotDevice(host, config=DeviceConfig(host, timeout=100))
assert dev.protocol._transport._timeout == 100
dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) dev = SmartDevice(host, config=DeviceConfig(host, timeout=100))
assert dev.protocol._transport._timeout == 100 assert dev.protocol._transport._timeout == 100
@ -258,7 +281,7 @@ async def test_create_thin_wrapper():
credentials=Credentials("username", "password"), credentials=Credentials("username", "password"),
) )
with patch("kasa.device_factory.connect", return_value=mock) as connect: with patch("kasa.device_factory.connect", return_value=mock) as connect:
dev = await SmartDevice.connect(config=config) dev = await Device.connect(config=config)
assert dev is mock assert dev is mock
connect.assert_called_once_with( connect.assert_called_once_with(
@ -268,7 +291,7 @@ async def test_create_thin_wrapper():
@device_iot @device_iot
async def test_modules_not_supported(dev: SmartDevice): async def test_modules_not_supported(dev: IotDevice):
"""Test that unsupported modules do not break the device.""" """Test that unsupported modules do not break the device."""
for module in dev.modules.values(): for module in dev.modules.values():
assert module.is_supported is not None assert module.is_supported is not None
@ -277,6 +300,21 @@ async def test_modules_not_supported(dev: SmartDevice):
assert module.is_supported is not None assert module.is_supported is not None
@pytest.mark.parametrize(
"device_class, use_class", kasa.deprecated_smart_devices.items()
)
def test_deprecated_devices(device_class, use_class):
package_name = ".".join(use_class.__module__.split(".")[:-1])
msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead"
with pytest.deprecated_call(match=msg):
getattr(kasa, device_class)
packages = package_name.split(".")
module = __import__(packages[0])
for _ in packages[1:]:
module = importlib.import_module(package_name, package=module.__name__)
getattr(module, use_class.__name__)
def check_mac(x): def check_mac(x):
if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()):
return x return x

View File

@ -2,7 +2,8 @@ from datetime import datetime
import pytest import pytest
from kasa import SmartDeviceException, SmartStrip from kasa import SmartDeviceException
from kasa.iot import IotStrip
from .conftest import handle_turn_on, strip, turn_on from .conftest import handle_turn_on, strip, turn_on
@ -68,7 +69,7 @@ async def test_children_on_since(dev):
@strip @strip
async def test_get_plug_by_name(dev: SmartStrip): async def test_get_plug_by_name(dev: IotStrip):
name = dev.children[0].alias name = dev.children[0].alias
assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type]
@ -77,7 +78,7 @@ async def test_get_plug_by_name(dev: SmartStrip):
@strip @strip
async def test_get_plug_by_index(dev: SmartStrip): async def test_get_plug_by_index(dev: IotStrip):
assert dev.get_plug_by_index(0) == dev.children[0] assert dev.get_plug_by_index(0) == dev.children[0]
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):

View File

@ -1,7 +1,7 @@
import datetime import datetime
from unittest.mock import Mock from unittest.mock import Mock
from kasa.modules import Usage from kasa.iot.modules import Usage
def test_usage_convert_stat_data(): def test_usage_convert_stat_data():