Refactor devices into subpackages and deprecate old names ()

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

View File

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

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.
"""
from importlib.metadata import version
from typing import TYPE_CHECKING
from warnings import warn
from kasa.bulb import Bulb
from kasa.credentials import Credentials
from kasa.device import Device
from kasa.device_type import DeviceType
from kasa.deviceconfig import (
ConnectionType,
DeviceConfig,
@ -29,18 +33,14 @@ from kasa.exceptions import (
TimeoutException,
UnsupportedDeviceException,
)
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import (
IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401
)
from kasa.plug import Plug
from kasa.protocol import BaseProtocol
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.smartdevice import DeviceType, SmartDevice
from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug
from kasa.smartprotocol import SmartProtocol
from kasa.smartstrip import SmartStrip
__version__ = version("python-kasa")
@ -50,18 +50,15 @@ __all__ = [
"BaseProtocol",
"IotProtocol",
"SmartProtocol",
"SmartBulb",
"SmartBulbPreset",
"BulbPreset",
"TurnOnBehaviors",
"TurnOnBehavior",
"DeviceType",
"EmeterStatus",
"SmartDevice",
"Device",
"Bulb",
"Plug",
"SmartDeviceException",
"SmartPlug",
"SmartStrip",
"SmartDimmer",
"SmartLightStrip",
"AuthenticationException",
"UnsupportedDeviceException",
"TimeoutException",
@ -72,11 +69,55 @@ __all__ = [
"DeviceFamilyType",
]
from . import iot
deprecated_names = ["TPLinkSmartHomeProtocol"]
deprecated_smart_devices = {
"SmartDevice": iot.IotDevice,
"SmartPlug": iot.IotPlug,
"SmartBulb": iot.IotBulb,
"SmartLightStrip": iot.IotLightStrip,
"SmartStrip": iot.IotStrip,
"SmartDimmer": iot.IotDimmer,
"SmartBulbPreset": BulbPreset,
}
def __getattr__(name):
if name in deprecated_names:
warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1)
return globals()[f"_deprecated_{name}"]
if name in deprecated_smart_devices:
new_class = deprecated_smart_devices[name]
package_name = ".".join(new_class.__module__.split(".")[:-1])
warn(
f"{name} is deprecated, use {new_class.__name__} "
+ f"from package {package_name} instead or use Discover.discover_single()"
+ " and Device.connect() to support new protocols",
DeprecationWarning,
stacklevel=1,
)
return new_class
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if TYPE_CHECKING:
SmartDevice = Device
SmartBulb = iot.IotBulb
SmartPlug = iot.IotPlug
SmartLightStrip = iot.IotLightStrip
SmartStrip = iot.IotStrip
SmartDimmer = iot.IotDimmer
SmartBulbPreset = BulbPreset
# Instanstiate all classes so the type checkers catch abstract issues
from . import smart
smart.SmartDevice("127.0.0.1")
smart.SmartPlug("127.0.0.1")
smart.SmartBulb("127.0.0.1")
iot.IotDevice("127.0.0.1")
iot.IotPlug("127.0.0.1")
iot.IotBulb("127.0.0.1")
iot.IotLightStrip("127.0.0.1")
iot.IotStrip("127.0.0.1")
iot.IotDimmer("127.0.0.1")

144
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 (
AuthenticationException,
Bulb,
ConnectionType,
Credentials,
Device,
DeviceConfig,
DeviceFamilyType,
Discover,
EncryptType,
SmartBulb,
SmartDevice,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartStrip,
SmartDeviceException,
UnsupportedDeviceException,
)
from kasa.discover import DiscoveryResult
from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip
from kasa.smart import SmartBulb, SmartDevice, SmartPlug
try:
from pydantic.v1 import ValidationError
@ -62,11 +61,18 @@ echo = _do_echo
TYPE_TO_CLASS = {
"plug": SmartPlug,
"bulb": SmartBulb,
"dimmer": SmartDimmer,
"strip": SmartStrip,
"lightstrip": SmartLightStrip,
"plug": IotPlug,
"bulb": IotBulb,
"dimmer": IotDimmer,
"strip": IotStrip,
"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]
@ -80,7 +86,7 @@ SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"]
click.anyio_backend = "asyncio"
pass_dev = click.make_pass_decorator(SmartDevice)
pass_dev = click.make_pass_decorator(Device)
class ExceptionHandlerGroup(click.Group):
@ -110,8 +116,8 @@ def json_formatter_cb(result, **kwargs):
"""
return str(val)
@to_serializable.register(SmartDevice)
def _device_to_serializable(val: SmartDevice):
@to_serializable.register(Device)
def _device_to_serializable(val: Device):
"""Serialize smart device data, just using the last update raw payload."""
return val.internal_state
@ -261,7 +267,7 @@ async def cli(
# no need to perform any checks if we are just displaying the help
if sys.argv[-1] == "--help":
# Context object is required to avoid crashing on sub-groups
ctx.obj = SmartDevice(None)
ctx.obj = Device(None)
return
# If JSON output is requested, disable echo
@ -340,7 +346,7 @@ async def cli(
timeout=timeout,
connection_type=ctype,
)
dev = await SmartDevice.connect(config=config)
dev = await Device.connect(config=config)
else:
echo("No --type or --device-family and --encrypt-type defined, discovering..")
dev = await Discover.discover_single(
@ -384,7 +390,7 @@ async def scan(dev):
@click.option("--keytype", prompt=True)
@click.option("--password", prompt=True, hide_input=True)
@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."""
echo(f"Asking the device to connect to {ssid}..")
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")
async def print_discovered(dev: SmartDevice):
async def print_discovered(dev: Device):
async with sem:
try:
await dev.update()
@ -526,7 +532,7 @@ async def sysinfo(dev):
@cli.command()
@pass_dev
@click.pass_context
async def state(ctx, dev: SmartDevice):
async def state(ctx, dev: Device):
"""Print out device state and versions."""
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:
echo("Index can only used for power strips!")
return
dev = cast(SmartStrip, dev)
dev = dev.get_plug_by_index(index)
if new_alias is not None:
@ -611,7 +616,7 @@ async def alias(dev, new_alias, index):
@click.argument("module")
@click.argument("command")
@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."""
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, 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.argument("command")
@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."""
if parameters is not None:
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))
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("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@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.
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!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
elif name:
@ -660,6 +669,12 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase):
echo("Device has no emeter")
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:
echo("Erasing emeter statistics..")
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("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@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.
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.option("--transition", type=int, required=False)
@pass_dev
async def brightness(dev: SmartBulb, brightness: int, transition: int):
async def brightness(dev: Bulb, brightness: int, transition: int):
"""Get or set brightness."""
if not dev.is_dimmable:
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)
@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."""
if not dev.is_variable_color_temp:
echo("Device does not support color temperature")
@ -852,14 +867,13 @@ async def time(dev):
@click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False)
@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."""
if index is not None or name is not None:
if not dev.is_strip:
echo("Index and name are only for power strips!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
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("--transition", type=int, required=False)
@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."""
if index is not None or name is not None:
if not dev.is_strip:
echo("Index and name are only for power strips!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
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("--transition", type=int, required=False)
@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."""
if index is not None or name is not None:
if not dev.is_strip:
echo("Index and name are only for power strips!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
elif name:
@ -970,10 +982,10 @@ async def presets(ctx):
@presets.command(name="list")
@pass_dev
def presets_list(dev: SmartBulb):
def presets_list(dev: IotBulb):
"""List presets."""
if not dev.is_bulb:
echo("Presets only supported on bulbs")
if not dev.is_bulb or not isinstance(dev, IotBulb):
echo("Presets only supported on iot bulbs")
return
for preset in dev.presets:
@ -989,9 +1001,7 @@ def presets_list(dev: SmartBulb):
@click.option("--saturation", type=int)
@click.option("--temperature", type=int)
@pass_dev
async def presets_modify(
dev: SmartBulb, index, brightness, hue, saturation, temperature
):
async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, temperature):
"""Modify a preset."""
for preset in dev.presets:
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("--last", is_flag=True)
@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."""
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()
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):
"""Update device credentials for authenticated devices."""
# Importing here as this is not really a public interface for now
from kasa.tapo import TapoDevice
if not isinstance(dev, TapoDevice):
if not isinstance(dev, SmartDevice):
raise NotImplementedError(
"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 .aestransport import AesTransport
from .device import Device
from .deviceconfig import DeviceConfig
from .exceptions import SmartDeviceException, UnsupportedDeviceException
from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip
from .iotprotocol import IotProtocol
from .klaptransport import KlapTransport, KlapTransportV2
from .protocol import (
BaseProtocol,
BaseTransport,
)
from .smartbulb import SmartBulb
from .smartdevice import SmartDevice
from .smartdimmer import SmartDimmer
from .smartlightstrip import SmartLightStrip
from .smartplug import SmartPlug
from .smart import SmartBulb, SmartPlug
from .smartprotocol import SmartProtocol
from .smartstrip import SmartStrip
from .tapo import TapoBulb, TapoPlug
from .xortransport import XorTransport
_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.
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}"
)
device_class: Optional[Type[SmartDevice]]
device_class: Optional[Type[Device]]
device: Optional[Device] = None
if isinstance(protocol, IotProtocol) and isinstance(
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."""
if "system" not in info or "get_sysinfo" not in info["system"]:
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!")
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
return SmartDimmer
return IotDimmer
if "smartplug" in type_.lower():
if "children" in sysinfo:
return SmartStrip
return IotStrip
return SmartPlug
return IotPlug
if "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length
return SmartLightStrip
return IotLightStrip
return SmartBulb
return IotBulb
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."""
supported_device_types: Dict[str, Type[SmartDevice]] = {
"SMART.TAPOPLUG": TapoPlug,
"SMART.TAPOBULB": TapoBulb,
"SMART.TAPOSWITCH": TapoBulb,
"SMART.KASAPLUG": TapoPlug,
"SMART.KASASWITCH": TapoBulb,
"IOT.SMARTPLUGSWITCH": SmartPlug,
"IOT.SMARTBULB": SmartBulb,
supported_device_types: Dict[str, Type[Device]] = {
"SMART.TAPOPLUG": SmartPlug,
"SMART.TAPOBULB": SmartBulb,
"SMART.TAPOSWITCH": SmartBulb,
"SMART.KASAPLUG": SmartPlug,
"SMART.KASASWITCH": SmartBulb,
"IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb,
}
return supported_device_types.get(device_type)

View File

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

View File

@ -15,6 +15,7 @@ try:
except ImportError:
from pydantic import BaseModel, ValidationError # pragma: no cover
from kasa import Device
from kasa.credentials import Credentials
from kasa.device_factory import (
get_device_class_from_family,
@ -22,17 +23,21 @@ from kasa.device_factory import (
get_protocol,
)
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 loads as json_loads
from kasa.smartdevice import SmartDevice, SmartDeviceException
from kasa.xortransport import XorEncryption
_LOGGER = logging.getLogger(__name__)
OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]]
DeviceDict = Dict[str, SmartDevice]
OnDiscoveredCallable = Callable[[Device], Awaitable[None]]
DeviceDict = Dict[str, Device]
class _DiscoverProtocol(asyncio.DatagramProtocol):
@ -121,7 +126,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
return
self.seen_hosts.add(ip)
device = None
device: Optional[Device] = None
config = DeviceConfig(host=ip, port_override=self.port)
if self.credentials:
@ -300,7 +305,7 @@ class Discover:
port: Optional[int] = None,
timeout: Optional[int] = None,
credentials: Optional[Credentials] = None,
) -> SmartDevice:
) -> Device:
"""Discover a single device by the given IP address.
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}")
@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."""
if "result" in info:
discovery_result = DiscoveryResult(**info["result"])
@ -397,7 +402,7 @@ class Discover:
return get_device_class_from_sys_info(info)
@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."""
try:
info = json_loads(XorEncryption.decrypt(data))
@ -408,7 +413,7 @@ class Discover:
_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)
sys_info = info["system"]["get_sysinfo"]
if device_type := sys_info.get("mic_type", sys_info.get("type")):
@ -423,7 +428,7 @@ class Discover:
def _get_device_instance(
data: bytes,
config: DeviceConfig,
) -> SmartDevice:
) -> Device:
"""Get SmartDevice from the new 20002 response."""
try:
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 re
from enum import Enum
from typing import Any, Dict, List, NamedTuple, Optional, cast
from typing import Any, Dict, List, Optional, cast
try:
from pydantic.v1 import BaseModel, Field, root_validator
except ImportError:
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 .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):
@ -116,7 +86,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__)
class SmartBulb(SmartDevice):
class IotBulb(IotDevice, Bulb):
r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once.
@ -132,7 +102,7 @@ class SmartBulb(SmartDevice):
Examples:
>>> import asyncio
>>> bulb = SmartBulb("127.0.0.1")
>>> bulb = IotBulb("127.0.0.1")
>>> asyncio.run(bulb.update())
>>> print(bulb.alias)
Bulb2
@ -198,7 +168,7 @@ class SmartBulb(SmartDevice):
Bulb configuration presets can be accessed using the :func:`presets` property:
>>> 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`
instance to :func:`save_preset` method:
@ -373,10 +343,6 @@ class SmartBulb(SmartDevice):
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
async def set_hsv(
self,
@ -534,11 +500,11 @@ class SmartBulb(SmartDevice):
@property # type: ignore
@requires_update
def presets(self) -> List[SmartBulbPreset]:
def presets(self) -> List[BulbPreset]:
"""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.
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 inspect
import logging
from dataclasses import dataclass
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_type import DeviceType
from .deviceconfig import DeviceConfig
from .emeterstatus import EmeterStatus
from .exceptions import SmartDeviceException
from .iotprotocol import IotProtocol
from .modules import Emeter, Module
from .protocol import BaseProtocol
from .xortransport import XorTransport
from ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import SmartDeviceException
from ..protocol import BaseProtocol
from .modules import Emeter, IotModule
_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):
"""Update dict recursively."""
for k, v in u.items():
@ -92,17 +72,17 @@ def _parse_features(features: str) -> Set[str]:
return set(features.split(":"))
class SmartDevice:
class IotDevice(Device):
"""Base class for all supported device types.
You don't usually want to initialize this class manually,
but either use :class:`Discover` class, or use one of the subclasses:
* :class:`SmartPlug`
* :class:`SmartBulb`
* :class:`SmartStrip`
* :class:`SmartDimmer`
* :class:`SmartLightStrip`
* :class:`IotPlug`
* :class:`IotBulb`
* :class:`IotStrip`
* :class:`IotDimmer`
* :class:`IotLightStrip`
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
@ -115,7 +95,7 @@ class SmartDevice:
Examples:
>>> import asyncio
>>> dev = SmartDevice("127.0.0.1")
>>> dev = IotDevice("127.0.0.1")
>>> asyncio.run(dev.update())
All devices provide several informational properties:
@ -200,59 +180,24 @@ class SmartDevice:
config: Optional[DeviceConfig] = None,
protocol: Optional[BaseProtocol] = None,
) -> None:
"""Create a new SmartDevice instance.
: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
"""Create a new IotDevice instance."""
super().__init__(host=host, config=config, protocol=protocol)
self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._features: Set[str] = set()
self.modules: Dict[str, Any] = {}
self.children: List["SmartDevice"] = []
self._children: Sequence["IotDevice"] = []
@property
def host(self) -> str:
"""The device host."""
return self.protocol._transport._host
def children(self) -> Sequence["IotDevice"]:
"""Return list of children."""
return self._children
@host.setter
def host(self, value):
"""Set the device host.
@children.setter
def children(self, children):
"""Initialize from a list of children."""
self._children = children
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
def add_module(self, name: str, module: Module):
def add_module(self, name: str, module: IotModule):
"""Register a module."""
if name in self.modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name)
@ -291,7 +236,7 @@ class SmartDevice:
request = self._create_request(target, cmd, arg, child_ids)
try:
response = await self.protocol.query(request=request)
response = await self._raw_query(request=request)
except Exception as ex:
raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex
@ -631,13 +576,7 @@ class SmartDevice:
"""Turn off the device."""
raise NotImplementedError("Device subclass needs to implement this.")
@property # type: ignore
@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:
async def turn_on(self, **kwargs) -> Optional[Dict]:
"""Turn device on."""
raise NotImplementedError("Device subclass needs to implement this.")
@ -714,77 +653,11 @@ class SmartDevice:
)
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
def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct."""
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
def internal_state(self) -> Any:
"""Return the internal state of the instance.
@ -793,47 +666,3 @@ class SmartDevice:
This should only be used for debugging purposes.
"""
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 typing import Any, Dict, Optional
from kasa.deviceconfig import DeviceConfig
from kasa.modules import AmbientLight, Motion
from kasa.protocol import BaseProtocol
from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update
from kasa.smartplug import SmartPlug
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocol import BaseProtocol
from .iotdevice import SmartDeviceException, requires_update
from .iotplug import IotPlug
from .modules import AmbientLight, Motion
class ButtonAction(Enum):
@ -32,7 +33,7 @@ class FadeType(Enum):
FadeOff = "fade_off"
class SmartDimmer(SmartPlug):
class IotDimmer(IotPlug):
r"""Representation of a TP-Link Smart Dimmer.
Dimmers work similarly to plugs, but provide also support for
@ -50,7 +51,7 @@ class SmartDimmer(SmartPlug):
Examples:
>>> import asyncio
>>> dimmer = SmartDimmer("192.168.1.105")
>>> dimmer = IotDimmer("192.168.1.105")
>>> asyncio.run(dimmer.turn_on())
>>> dimmer.brightness
25

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
"""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
# [{"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}]}]
class AmbientLight(Module):
class AmbientLight(IotModule):
"""Implements ambient light controls for the motion sensor."""
def query(self):

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@
from enum import Enum
from typing import Optional
from ..exceptions import SmartDeviceException
from .module import Module
from ...exceptions import SmartDeviceException
from .module import IotModule
class Range(Enum):
@ -20,7 +20,7 @@ class Range(Enum):
# "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."""
def query(self):

View File

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

View File

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

View File

@ -2,10 +2,10 @@
from datetime import datetime
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."""
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**)."""
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 ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset
from .tapodevice import TapoDevice
from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange
from ..smartprotocol import SmartProtocol
from .smartdevice import SmartDevice
AVAILABLE_EFFECTS = {
"L1": "Party",
@ -11,12 +15,22 @@ AVAILABLE_EFFECTS = {
}
class TapoBulb(TapoDevice, SmartBulb):
class SmartBulb(SmartDevice, 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
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
@ -257,6 +271,6 @@ class TapoBulb(TapoDevice, SmartBulb):
return info
@property
def presets(self) -> List[SmartBulbPreset]:
def presets(self) -> List[BulbPreset]:
"""Return a list of available bulb setting presets."""
return []

View File

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

View File

@ -1,26 +1,25 @@
"""Module for a TAPO device."""
"""Module for a SMART device."""
import base64
import logging
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 ..device import Device, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationException, SmartDeviceException
from ..modules import Emeter
from ..smartdevice import SmartDevice, WifiNetwork
from ..smartprotocol import SmartProtocol
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .childdevice import ChildDevice
from .smartchilddevice import SmartChildDevice
class TapoDevice(SmartDevice):
"""Base class to represent a TAPO device."""
class SmartDevice(Device):
"""Base class to represent a SMART protocol based device."""
def __init__(
self,
@ -36,39 +35,31 @@ class TapoDevice(SmartDevice):
self.protocol: SmartProtocol
self._components_raw: Optional[Dict[str, Any]] = None
self._components: Dict[str, int] = {}
self._children: Dict[str, "ChildDevice"] = {}
self._children: Dict[str, "SmartChildDevice"] = {}
self._energy: Dict[str, Any] = {}
self._state_information: Dict[str, Any] = {}
self._time: Dict[str, Any] = {}
async def _initialize_children(self):
"""Initialize children for power strips."""
children = self._last_update["child_info"]["child_device_list"]
# TODO: Use the type information to construct children,
# as hubs can also have them.
from .childdevice import ChildDevice
from .smartchilddevice import SmartChildDevice
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
}
self._device_type = DeviceType.Strip
@property
def children(self):
"""Return list of children.
This is just to keep the existing SmartDevice API intact.
"""
def children(self) -> Sequence["SmartDevice"]:
"""Return list of children."""
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):
"""Update the device."""
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."""
if "energy_monitoring" in self._components:
self.emeter_type = "emeter"
self.modules["emeter"] = Emeter(self, self.emeter_type)
@property
def sys_info(self) -> Dict[str, Any]:
@ -218,9 +208,9 @@ class TapoDevice(SmartDevice):
return self._last_update
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:
res = await self.protocol.query({cmd: arg})
res = await self.protocol.query({method: params})
return res
@ -276,6 +266,13 @@ class TapoDevice(SmartDevice):
"""Return adjusted emeter information."""
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
def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status."""
@ -298,6 +295,17 @@ class TapoDevice(SmartDevice):
"""Get the emeter value for today."""
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]:
"""Scan for available wifi networks."""

View File

@ -1,17 +1,17 @@
"""Module for a TAPO Plug."""
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, cast
from typing import Any, Dict, Optional
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..smartdevice import DeviceType
from ..plug import Plug
from ..smartprotocol import SmartProtocol
from .tapodevice import TapoDevice
from .smartdevice import SmartDevice
_LOGGER = logging.getLogger(__name__)
class TapoPlug(TapoDevice):
class SmartPlug(SmartDevice, Plug):
"""Class to represent a TAPO Plug."""
def __init__(
@ -35,11 +35,3 @@ class TapoPlug(TapoDevice):
"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 (
Credentials,
Device,
DeviceConfig,
Discover,
SmartBulb,
SmartDevice,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartProtocol,
SmartStrip,
)
from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip
from kasa.protocol import BaseTransport
from kasa.tapo import TapoBulb, TapoPlug
from kasa.smart import SmartBulb, SmartPlug
from kasa.xortransport import XorEncryption
from .fakeprotocol_iot import FakeIotProtocol
@ -350,37 +346,37 @@ def device_for_file(model, protocol):
if protocol == "SMART":
for d in PLUGS_SMART:
if d in model:
return TapoPlug
return SmartPlug
for d in BULBS_SMART:
if d in model:
return TapoBulb
return SmartBulb
for d in DIMMERS_SMART:
if d in model:
return TapoBulb
return SmartBulb
for d in STRIPS_SMART:
if d in model:
return TapoPlug
return SmartPlug
else:
for d in STRIPS_IOT:
if d in model:
return SmartStrip
return IotStrip
for d in PLUGS_IOT:
if d in model:
return SmartPlug
return IotPlug
# Light strips are recognized also as bulbs, so this has to go first
for d in BULBS_IOT_LIGHT_STRIP:
if d in model:
return SmartLightStrip
return IotLightStrip
for d in BULBS_IOT:
if d in model:
return SmartBulb
return IotBulb
for d in DIMMERS_IOT:
if d in model:
return SmartDimmer
return IotDimmer
raise Exception("Unable to find type for %s", model)
@ -446,11 +442,11 @@ async def dev(request):
IP_MODEL_CACHE[ip] = model = d.model
if model not in file:
pytest.skip(f"skipping file {file}")
dev: SmartDevice = (
dev: Device = (
d if d else await _discover_update_and_close(ip, username, password)
)
else:
dev: SmartDevice = await get_device_for_file(file, protocol)
dev: Device = await get_device_for_file(file, protocol)
yield dev

View File

@ -7,7 +7,8 @@ from voluptuous import (
Schema,
)
from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException
from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException
from kasa.iot import IotBulb
from .conftest import (
bulb,
@ -27,7 +28,7 @@ from .test_smartdevice import SYSINFO_SCHEMA
@bulb
async def test_bulb_sysinfo(dev: SmartBulb):
async def test_bulb_sysinfo(dev: Bulb):
assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info)
@ -40,7 +41,7 @@ async def test_bulb_sysinfo(dev: SmartBulb):
@bulb
async def test_state_attributes(dev: SmartBulb):
async def test_state_attributes(dev: Bulb):
assert "Brightness" in dev.state_information
assert dev.state_information["Brightness"] == dev.brightness
@ -49,7 +50,7 @@ async def test_state_attributes(dev: SmartBulb):
@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):
monkeypatch.setitem(
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
async def test_get_light_state(dev: SmartBulb):
async def test_get_light_state(dev: IotBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state())
@color_bulb
@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)
assert dev.is_color
@ -83,8 +84,8 @@ async def test_hsv(dev: SmartBulb, turn_on):
@color_bulb_iot
async def test_set_hsv_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with(
@ -95,31 +96,31 @@ async def test_set_hsv_transition(dev: SmartBulb, mocker):
@color_bulb
@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)
assert dev.is_color
for invalid_hue in [-1, 361, 0.5]:
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]:
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]:
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
async def test_color_state_information(dev: SmartBulb):
async def test_color_state_information(dev: Bulb):
assert "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv
@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
with pytest.raises(SmartDeviceException):
@ -129,7 +130,7 @@ async def test_hsv_on_non_color(dev: SmartBulb):
@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 dev.state_information["Color temperature"] == dev.color_temp
@ -141,7 +142,7 @@ async def test_variable_temp_state_information(dev: SmartBulb):
@variable_temp
@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 dev.set_color_temp(2700)
await dev.update()
@ -149,15 +150,15 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on):
@variable_temp_iot
async def test_set_color_temp_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@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")
assert dev.valid_temperature_range == (2700, 5000)
@ -165,7 +166,7 @@ async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog):
@variable_temp
async def test_out_of_range_temperature(dev: SmartBulb):
async def test_out_of_range_temperature(dev: Bulb):
with pytest.raises(ValueError):
await dev.set_color_temp(1000)
with pytest.raises(ValueError):
@ -173,7 +174,7 @@ async def test_out_of_range_temperature(dev: SmartBulb):
@non_variable_temp
async def test_non_variable_temp(dev: SmartBulb):
async def test_non_variable_temp(dev: Bulb):
with pytest.raises(SmartDeviceException):
await dev.set_color_temp(2700)
@ -186,7 +187,7 @@ async def test_non_variable_temp(dev: SmartBulb):
@dimmable
@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)
assert dev.is_dimmable
@ -199,12 +200,12 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on):
assert dev.brightness == 10
with pytest.raises(ValueError):
await dev.set_brightness("foo")
await dev.set_brightness("foo") # type: ignore[arg-type]
@bulb_iot
async def test_turn_on_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.turn_on(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
async def test_dimmable_brightness_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
await dev.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@dimmable
async def test_invalid_brightness(dev: SmartBulb):
async def test_invalid_brightness(dev: Bulb):
assert dev.is_dimmable
with pytest.raises(ValueError):
@ -234,7 +235,7 @@ async def test_invalid_brightness(dev: SmartBulb):
@non_dimmable
async def test_non_dimmable(dev: SmartBulb):
async def test_non_dimmable(dev: Bulb):
assert not dev.is_dimmable
with pytest.raises(SmartDeviceException):
@ -245,9 +246,9 @@ async def test_non_dimmable(dev: SmartBulb):
@bulb_iot
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
await dev.turn_on()
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
async def test_list_presets(dev: SmartBulb):
async def test_list_presets(dev: IotBulb):
presets = dev.presets
assert len(presets) == len(dev.sys_info["preferred_state"])
@ -272,7 +273,7 @@ async def test_list_presets(dev: SmartBulb):
@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."""
if not dev.presets:
pytest.skip("Some strips do not support presets")
@ -284,7 +285,7 @@ async def test_modify_preset(dev: SmartBulb, mocker):
"saturation": 0,
"color_temp": 0,
}
preset = SmartBulbPreset(**data)
preset = BulbPreset(**data)
assert preset.index == 0
assert preset.brightness == 10
@ -297,7 +298,7 @@ async def test_modify_preset(dev: SmartBulb, mocker):
with pytest.raises(SmartDeviceException):
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"),
[
(
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},
),
(
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},
),
],
)
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."""
if not dev.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)
query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload)

View File

@ -3,8 +3,8 @@ import sys
import pytest
from kasa.smart.smartchilddevice import SmartChildDevice
from kasa.smartprotocol import _ChildProtocolWrapper
from kasa.tapo.childdevice import ChildDevice
from .conftest import strip_smart
@ -42,7 +42,7 @@ async def test_childdevice_update(dev, dummy_protocol, mocker):
sys.version_info < (3, 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."""
assert len(dev.children) > 0

View File

@ -7,8 +7,8 @@ from asyncclick.testing import CliRunner
from kasa import (
AuthenticationException,
Device,
EmeterStatus,
SmartDevice,
SmartDeviceException,
UnsupportedDeviceException,
)
@ -27,6 +27,7 @@ from kasa.cli import (
wifi,
)
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
@ -107,9 +108,9 @@ async def test_alias(dev):
async def test_raw_command(dev, mocker):
runner = CliRunner()
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"]
else:
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()
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 realtime_emeter.call_count == 2
monthly = mocker.patch.object(dev, "get_emeter_monthly")
monthly.return_value = {1: 1234}
if isinstance(dev, IotDevice):
monthly = mocker.patch.object(dev, "get_emeter_monthly")
monthly.return_value = {1: 1234}
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 "1, 1234" in res.output
monthly.assert_called_with(year=1900)
daily = mocker.patch.object(dev, "get_emeter_daily")
daily.return_value = {1: 1234}
if isinstance(dev, IotDevice):
daily = mocker.patch.object(dev, "get_emeter_daily")
daily.return_value = {1: 1234}
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 "1, 1234" in res.output
daily.assert_called_with(year=1900, month=12)
@ -279,7 +288,7 @@ async def test_brightness(dev):
@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."""
mocker.patch("kasa.Discover.discover", return_value=[dev])
runner = CliRunner()
@ -292,10 +301,10 @@ async def test_json_output(dev: SmartDevice, mocker):
async def test_credentials(discovery_mock, mocker):
"""Test credentials are passed correctly from cli to device."""
# Patch state to echo username and password
pass_dev = click.make_pass_decorator(SmartDevice)
pass_dev = click.make_pass_decorator(Device)
@pass_dev
async def _state(dev: SmartDevice):
async def _state(dev: Device):
if dev.credentials:
click.echo(
f"Username:{dev.credentials.username} Password:{dev.credentials.password}"
@ -513,10 +522,10 @@ async def test_type_param(device_type, mocker):
runner = CliRunner()
result_device = FileNotFoundError
pass_dev = click.make_pass_decorator(SmartDevice)
pass_dev = click.make_pass_decorator(Device)
@pass_dev
async def _state(dev: SmartDevice):
async def _state(dev: Device):
nonlocal result_device
result_device = dev

View File

@ -6,8 +6,8 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
from kasa import (
Credentials,
Device,
Discover,
SmartDevice,
SmartDeviceException,
)
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.SmartProtocol.query", return_value=all_fixture_data)
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

View File

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

View File

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

View File

@ -10,9 +10,9 @@ from async_timeout import timeout as asyncio_timeout
from kasa import (
Credentials,
Device,
DeviceType,
Discover,
SmartDevice,
SmartDeviceException,
)
from kasa.deviceconfig import (
@ -21,6 +21,7 @@ from kasa.deviceconfig import (
)
from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps
from kasa.exceptions import AuthenticationException, UnsupportedDeviceException
from kasa.iot import IotDevice
from kasa.xortransport import XorEncryption
from .conftest import (
@ -55,14 +56,14 @@ UNSUPPORTED = {
@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")
assert d.is_plug
assert d.device_type == DeviceType.Plug
@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")
# TODO: light_strip is a special case for now to force bulb tests on it
if not d.is_light_strip:
@ -71,21 +72,21 @@ async def test_type_detection_bulb(dev: SmartDevice):
@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")
assert d.is_strip
assert d.device_type == DeviceType.Strip
@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")
assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer
@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")
assert d.is_light_strip
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(
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.port == custom_port or x.port == discovery_mock.default_port
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")
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.host == host
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
assert len(proto.unsupported_device_exceptions) == 1
dev = proto.discovered_devices[addr]
assert issubclass(dev.__class__, SmartDevice)
assert issubclass(dev.__class__, Device)
assert dev.host == addr
@ -298,7 +299,7 @@ async def test_discover_single_authentication(discovery_mock, mocker):
@new_discovery
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_dump = discover_info.get_dict()
discover_dump["alias"] = "foobar"
@ -323,7 +324,7 @@ async def test_discover_single_http_client(discovery_mock, mocker):
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)
@ -341,7 +342,7 @@ async def test_discover_http_client(discovery_mock, mocker):
http_client = aiohttp.ClientSession()
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)
if discovery_mock.default_port == 80:

View File

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

View File

@ -1,27 +1,28 @@
import pytest
from kasa import DeviceType, SmartLightStrip
from kasa import DeviceType
from kasa.exceptions import SmartDeviceException
from kasa.iot import IotLightStrip
from .conftest import lightstrip
@lightstrip
async def test_lightstrip_length(dev: SmartLightStrip):
async def test_lightstrip_length(dev: IotLightStrip):
assert dev.is_light_strip
assert dev.device_type == DeviceType.LightStrip
assert dev.length == dev.sys_info["length"]
@lightstrip
async def test_lightstrip_effect(dev: SmartLightStrip):
async def test_lightstrip_effect(dev: IotLightStrip):
assert isinstance(dev.effect, dict)
for k in ["brightness", "custom", "enable", "id", "name"]:
assert k in dev.effect
@lightstrip
async def test_effects_lightstrip_set_effect(dev: SmartLightStrip):
async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
with pytest.raises(SmartDeviceException):
await dev.set_effect("Not real")
@ -33,9 +34,9 @@ async def test_effects_lightstrip_set_effect(dev: SmartLightStrip):
@lightstrip
@pytest.mark.parametrize("brightness", [100, 50])
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)
if brightness == 100:
@ -51,9 +52,9 @@ async def test_effects_lightstrip_set_effect_brightness(
@lightstrip
@pytest.mark.parametrize("transition", [500, 1000])
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
if transition == 500:
@ -67,6 +68,6 @@ async def test_effects_lightstrip_set_effect_transition(
@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.effect_list

View File

@ -8,54 +8,54 @@ from kasa.tests.conftest import get_device_for_file
def test_bulb_examples(mocker):
"""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"))
mocker.patch("kasa.smartbulb.SmartBulb", return_value=p)
mocker.patch("kasa.smartbulb.SmartBulb.update")
res = xdoctest.doctest_module("kasa.smartbulb", "all")
mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p)
mocker.patch("kasa.iot.iotbulb.IotBulb.update")
res = xdoctest.doctest_module("kasa.iot.iotbulb", "all")
assert not res["failed"]
def test_smartdevice_examples(mocker):
"""Use HS110 for emeter examples."""
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.smartdevice.SmartDevice.update")
res = xdoctest.doctest_module("kasa.smartdevice", "all")
mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p)
mocker.patch("kasa.iot.iotdevice.IotDevice.update")
res = xdoctest.doctest_module("kasa.iot.iotdevice", "all")
assert not res["failed"]
def test_plug_examples(mocker):
"""Test plug examples."""
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.smartplug.SmartPlug.update")
res = xdoctest.doctest_module("kasa.smartplug", "all")
mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p)
mocker.patch("kasa.iot.iotplug.IotPlug.update")
res = xdoctest.doctest_module("kasa.iot.iotplug", "all")
assert not res["failed"]
def test_strip_examples(mocker):
"""Test strip examples."""
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.smartstrip.SmartStrip.update")
res = xdoctest.doctest_module("kasa.smartstrip", "all")
mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p)
mocker.patch("kasa.iot.iotstrip.IotStrip.update")
res = xdoctest.doctest_module("kasa.iot.iotstrip", "all")
assert not res["failed"]
def test_dimmer_examples(mocker):
"""Test dimmer examples."""
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.smartdimmer.SmartDimmer.update")
res = xdoctest.doctest_module("kasa.smartdimmer", "all")
mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p)
mocker.patch("kasa.iot.iotdimmer.IotDimmer.update")
res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all")
assert not res["failed"]
def test_lightstrip_examples(mocker):
"""Test lightstrip examples."""
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.smartlightstrip.SmartLightStrip.update")
res = xdoctest.doctest_module("kasa.smartlightstrip", "all")
mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p)
mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update")
res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all")
assert not res["failed"]

View File

@ -1,5 +1,8 @@
import importlib
import inspect
import pkgutil
import re
import sys
from datetime import datetime
from unittest.mock import Mock, patch
@ -17,20 +20,33 @@ from voluptuous import (
)
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 .fakeprotocol_iot import FakeIotProtocol
# List of all SmartXXX classes including the SmartDevice base class
smart_device_classes = [
dc
for (mn, dc) in inspect.getmembers(
kasa,
lambda member: inspect.isclass(member)
and (member == SmartDevice or issubclass(member, SmartDevice)),
)
]
def _get_subclasses(of_class):
package = sys.modules["kasa"]
subclasses = set()
for _, modname, _ in pkgutil.iter_modules(package.__path__):
importlib.import_module("." + modname, package="kasa")
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
@ -220,21 +236,26 @@ async def test_estimated_response_sizes(dev):
assert mod.estimated_query_response_size > 0
@pytest.mark.parametrize("device_class", smart_device_classes)
def test_device_class_ctors(device_class):
@device_classes
async def test_device_class_ctors(device_class_name_obj):
"""Make sure constructor api not broken for new and existing SmartDevices."""
host = "127.0.0.2"
port = 1234
credentials = Credentials("foo", "bar")
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.port == port
assert dev.credentials == credentials
@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."""
dev._last_update["some_module_not_being_updated"] = "should_be_kept"
await dev.update()
@ -244,6 +265,8 @@ async def test_modules_preserved(dev: SmartDevice):
async def test_create_smart_device_with_timeout():
"""Make sure timeout is passed to the protocol."""
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))
assert dev.protocol._transport._timeout == 100
@ -258,7 +281,7 @@ async def test_create_thin_wrapper():
credentials=Credentials("username", "password"),
)
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
connect.assert_called_once_with(
@ -268,7 +291,7 @@ async def test_create_thin_wrapper():
@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."""
for module in dev.modules.values():
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
@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):
if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()):
return x

View File

@ -2,7 +2,8 @@ from datetime import datetime
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
@ -68,7 +69,7 @@ async def test_children_on_since(dev):
@strip
async def test_get_plug_by_name(dev: SmartStrip):
async def test_get_plug_by_name(dev: IotStrip):
name = dev.children[0].alias
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
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]
with pytest.raises(SmartDeviceException):

View File

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