mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-01-04 11:57:00 +00:00
Refactor devices into subpackages and deprecate old names (#716)
* Refactor devices into subpackages and deprecate old names * Tweak and add tests * Fix linting * Remove duplicate implementations affecting project coverage * Update post review * Add device base class attributes and rename subclasses * Rename Module to BaseModule * Remove has_emeter_history * Fix missing _time in init * Update post review * Fix test_readmeexamples * Fix erroneously duped files * Clean up iot and smart imports * Update post latest review * Tweak Device docstring
This commit is contained in:
parent
6afd05be59
commit
0d119e63d0
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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
144
kasa/bulb.py
Normal 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."""
|
100
kasa/cli.py
100
kasa/cli.py
@ -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
353
kasa/device.py
Normal 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}>"
|
||||
)
|
@ -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)
|
||||
|
||||
|
@ -14,8 +14,6 @@ class DeviceType(Enum):
|
||||
StripSocket = "stripsocket"
|
||||
Dimmer = "dimmer"
|
||||
LightStrip = "lightstrip"
|
||||
TapoPlug = "tapoplug"
|
||||
TapoBulb = "tapobulb"
|
||||
Unknown = "unknown"
|
||||
|
||||
@staticmethod
|
||||
|
@ -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
16
kasa/iot/__init__.py
Normal 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",
|
||||
]
|
@ -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
|
@ -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]
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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",
|
@ -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):
|
@ -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):
|
@ -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
|
||||
|
||||
|
@ -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
|
@ -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):
|
@ -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):
|
@ -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):
|
@ -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
11
kasa/plug.py
Normal 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
7
kasa/smart/__init__.py
Normal 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"]
|
@ -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 []
|
@ -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,
|
@ -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."""
|
||||
|
@ -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)
|
@ -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"]
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
from kasa.smartdevice import DeviceType
|
||||
from kasa.device_type import DeviceType
|
||||
|
||||
|
||||
async def test_device_type_from_value():
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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():
|
||||
|
Loading…
Reference in New Issue
Block a user