From 70c94c60806fa2ae2a1543ac7268688631c26b04 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 26 Jan 2024 19:23:14 +0000 Subject: [PATCH 01/15] Refactor devices into subpackages and deprecate old names --- devtools/create_module_fixtures.py | 6 +- devtools/dump_devinfo.py | 12 +- kasa/__init__.py | 39 ++++-- kasa/cli.py | 78 ++++++------ kasa/device.py | 8 ++ kasa/device_factory.py | 44 +++---- kasa/discover.py | 14 +-- kasa/iot/__init__.py | 9 ++ kasa/{smartbulb.py => iot/bulb.py} | 23 ++-- kasa/{smartdevice.py => iot/device.py} | 31 ++--- kasa/{smartdimmer.py => iot/dimmer.py} | 10 +- .../{smartlightstrip.py => iot/lightstrip.py} | 14 +-- kasa/{ => iot}/modules/__init__.py | 0 kasa/{ => iot}/modules/ambientlight.py | 0 kasa/{ => iot}/modules/antitheft.py | 0 kasa/{ => iot}/modules/cloud.py | 0 kasa/{ => iot}/modules/countdown.py | 0 kasa/{ => iot}/modules/emeter.py | 2 +- kasa/iot/modules/module.py | 88 +++++++++++++ kasa/{ => iot}/modules/motion.py | 2 +- kasa/{ => iot}/modules/rulemodule.py | 0 kasa/{ => iot}/modules/schedule.py | 0 kasa/{ => iot}/modules/time.py | 2 +- kasa/{ => iot}/modules/usage.py | 0 kasa/{smartplug.py => iot/plug.py} | 8 +- kasa/{smartstrip.py => iot/strip.py} | 20 +-- kasa/module.py | 8 ++ kasa/smart/__init__.py | 6 + kasa/{tapo/tapobulb.py => smart/bulb.py} | 11 +- kasa/{tapo/tapodevice.py => smart/device.py} | 7 +- kasa/smart/modules/emeter.py | 111 +++++++++++++++++ kasa/{ => smart}/modules/module.py | 8 +- kasa/smart/modules/usage.py | 116 ++++++++++++++++++ kasa/{tapo/tapoplug.py => smart/plug.py} | 6 +- kasa/tapo/__init__.py | 6 - kasa/tests/conftest.py | 34 ++--- kasa/tests/test_bulb.py | 70 +++++------ kasa/tests/test_cli.py | 18 +-- kasa/tests/test_device_factory.py | 12 +- kasa/tests/test_device_type.py | 2 +- kasa/tests/test_dimmer.py | 12 +- kasa/tests/test_discovery.py | 24 ++-- kasa/tests/test_emeter.py | 2 +- kasa/tests/test_lightstrip.py | 18 +-- kasa/tests/test_readme_examples.py | 36 +++--- kasa/tests/test_smartdevice.py | 12 +- kasa/tests/test_strip.py | 6 +- kasa/tests/test_usage.py | 2 +- 48 files changed, 644 insertions(+), 293 deletions(-) create mode 100644 kasa/device.py create mode 100644 kasa/iot/__init__.py rename kasa/{smartbulb.py => iot/bulb.py} (94%) rename kasa/{smartdevice.py => iot/device.py} (97%) rename kasa/{smartdimmer.py => iot/dimmer.py} (96%) rename kasa/{smartlightstrip.py => iot/lightstrip.py} (92%) rename kasa/{ => iot}/modules/__init__.py (100%) rename kasa/{ => iot}/modules/ambientlight.py (100%) rename kasa/{ => iot}/modules/antitheft.py (100%) rename kasa/{ => iot}/modules/cloud.py (100%) rename kasa/{ => iot}/modules/countdown.py (100%) rename kasa/{ => iot}/modules/emeter.py (98%) create mode 100644 kasa/iot/modules/module.py rename kasa/{ => iot}/modules/motion.py (97%) rename kasa/{ => iot}/modules/rulemodule.py (100%) rename kasa/{ => iot}/modules/schedule.py (100%) rename kasa/{ => iot}/modules/time.py (96%) rename kasa/{ => iot}/modules/usage.py (100%) rename kasa/{smartplug.py => iot/plug.py} (93%) rename kasa/{smartstrip.py => iot/strip.py} (96%) create mode 100644 kasa/module.py create mode 100644 kasa/smart/__init__.py rename kasa/{tapo/tapobulb.py => smart/bulb.py} (96%) rename kasa/{tapo/tapodevice.py => smart/device.py} (98%) create mode 100644 kasa/smart/modules/emeter.py rename kasa/{ => smart}/modules/module.py (93%) create mode 100644 kasa/smart/modules/usage.py rename kasa/{tapo/tapoplug.py => smart/plug.py} (93%) delete mode 100644 kasa/tapo/__init__.py diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 1e0f17f72..bdd4af121 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -9,12 +9,12 @@ import typer -from kasa import Discover, SmartDevice +from kasa import Device, Discover app = typer.Typer() -def create_fixtures(dev: SmartDevice, outputdir: Path): +def create_fixtures(dev: Device, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): module_dir = outputdir / name @@ -43,7 +43,7 @@ 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: Device = asyncio.run(Discover.discover_single(host)) devs.append(dev) else: if network is None: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index e9ec56b7b..4d73b5216 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -19,18 +19,18 @@ import asyncclick as click +import kasa.iot as Iot +import kasa.smart as Smart from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest from kasa import ( AuthenticationException, Credentials, Discover, - SmartDevice, SmartDeviceException, TimeoutException, ) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode -from kasa.tapo.tapodevice import TapoDevice Call = namedtuple("Call", "module method") SmartCall = namedtuple("SmartCall", "module request should_succeed") @@ -113,9 +113,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: Iot.Device, batch_size: int): """Create a fixture for a single device instance.""" - if isinstance(device, TapoDevice): + if isinstance(device, Smart.Device): filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -268,7 +268,7 @@ def _echo_error(msg: str): async def _make_requests_or_exit( - device: SmartDevice, + device: Smart.Device, requests: List[SmartRequest], name: str, batch_size: int, @@ -313,7 +313,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: Smart.Device, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/__init__.py b/kasa/__init__.py index 121413b67..7b3e8057b 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -29,18 +29,18 @@ TimeoutException, UnsupportedDeviceException, ) +from kasa.iot.bulb import Bulb, BulbPreset, TurnOnBehavior, TurnOnBehaviors +from kasa.iot.device import Device, DeviceType +from kasa.iot.dimmer import Dimmer +from kasa.iot.lightstrip import LightStrip +from kasa.iot.plug import Plug +from kasa.iot.strip import Strip from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) 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,18 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "SmartBulb", - "SmartBulbPreset", + "Bulb", + "BulbPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "EmeterStatus", - "SmartDevice", + "Device", "SmartDeviceException", - "SmartPlug", - "SmartStrip", - "SmartDimmer", - "SmartLightStrip", + "Plug", + "Strip", + "Dimmer", + "LightStrip", "AuthenticationException", "UnsupportedDeviceException", "TimeoutException", @@ -72,11 +72,24 @@ "DeviceFamilyType", ] +from . import iot as Iot + deprecated_names = ["TPLinkSmartHomeProtocol"] +deprecated_smart_devices = { + "SmartDevice": Iot.Device, + "SmartPlug": Iot.Plug, + "SmartBulb": Iot.Bulb, + "SmartLightStrip": Iot.LightStrip, + "SmartStrip": Iot.Strip, + "SmartDimmer": Iot.Dimmer, +} 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: + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) + return deprecated_smart_devices[name] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/kasa/cli.py b/kasa/cli.py index 42b13b9bb..e9a726dc5 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,18 +13,18 @@ from kasa import ( AuthenticationException, + Bulb, ConnectionType, Credentials, + Device, DeviceConfig, DeviceFamilyType, + Dimmer, Discover, EncryptType, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, + LightStrip, + Plug, + Strip, UnsupportedDeviceException, ) from kasa.discover import DiscoveryResult @@ -62,11 +62,11 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, + "plug": Plug, + "bulb": Bulb, + "dimmer": Dimmer, + "strip": Strip, + "lightstrip": LightStrip, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -80,7 +80,7 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(SmartDevice) +pass_dev = click.make_pass_decorator(Device) class ExceptionHandlerGroup(click.Group): @@ -110,8 +110,8 @@ def to_serializable(val): """ 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 +261,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 @@ -339,7 +339,7 @@ def _nop_echo(*args, **kwargs): 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( @@ -383,7 +383,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) @@ -427,7 +427,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): 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() @@ -521,7 +521,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 @@ -584,7 +584,7 @@ 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 = cast(Strip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -606,7 +606,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) @@ -617,7 +617,7 @@ 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) @@ -634,7 +634,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. @@ -644,7 +644,7 @@ 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) + dev = cast(Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -696,7 +696,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. @@ -734,7 +734,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.") @@ -754,7 +754,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") @@ -847,14 +847,14 @@ 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) + dev = cast(Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -869,14 +869,14 @@ 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) + dev = cast(Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -891,14 +891,14 @@ 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) + dev = cast(Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -965,7 +965,7 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: SmartBulb): +def presets_list(dev: Bulb): """List presets.""" if not dev.is_bulb: echo("Presets only supported on bulbs") @@ -984,9 +984,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: Bulb, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: if preset.index == index: @@ -1014,7 +1012,7 @@ 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: Bulb, type, last, preset): """Modify bulb turn-on behavior.""" settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1051,9 +1049,9 @@ 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 + from kasa.smart import Device - if not isinstance(dev, TapoDevice): + if not isinstance(dev, Device): raise NotImplementedError( "Credentials can only be updated on authenticated devices." ) diff --git a/kasa/device.py b/kasa/device.py new file mode 100644 index 000000000..38b34aaa6 --- /dev/null +++ b/kasa/device.py @@ -0,0 +1,8 @@ +"""Module for Device base class.""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +class Device: + """Placeholder for interface or base class.""" diff --git a/kasa/device_factory.py b/kasa/device_factory.py index fdb5b1b49..7add9d5b4 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -3,7 +3,10 @@ import time from typing import Any, Dict, Optional, Tuple, Type +from . import iot as Iot +from . import smart as Smart from .aestransport import AesTransport +from .device import Device from .deviceconfig import DeviceConfig from .exceptions import SmartDeviceException, UnsupportedDeviceException from .iotprotocol import IotProtocol @@ -12,14 +15,7 @@ BaseProtocol, BaseTransport, ) -from .smartbulb import SmartBulb -from .smartdevice import SmartDevice -from .smartdimmer import SmartDimmer -from .smartlightstrip import SmartLightStrip -from .smartplug import 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 @@ } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": +async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Iot.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,7 @@ def _perf_log(has_params, perf_type): + f"{config.connection_type.device_family.value}" ) - device_class: Optional[Type[SmartDevice]] + device_class: Optional[Type[Device]] if isinstance(protocol, IotProtocol) and isinstance( protocol._transport, XorTransport @@ -100,7 +96,7 @@ def _perf_log(has_params, perf_type): ) -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[Iot.Device]: """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 +107,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 Iot.Dimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return SmartStrip + return Iot.Strip - return SmartPlug + return Iot.Plug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return SmartLightStrip + return Iot.LightStrip - return SmartBulb + return Iot.Bulb 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[Iot.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[Iot.Device]] = { + "SMART.TAPOPLUG": Smart.Plug, + "SMART.TAPOBULB": Smart.Bulb, + "SMART.TAPOSWITCH": Smart.Bulb, + "SMART.KASAPLUG": Smart.Plug, + "SMART.KASASWITCH": Smart.Bulb, + "IOT.SMARTPLUGSWITCH": Iot.Plug, + "IOT.SMARTBULB": Iot.Bulb, } return supported_device_types.get(device_type) diff --git a/kasa/discover.py b/kasa/discover.py index 8286387ae..b22f9d283 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -23,16 +23,16 @@ ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType from kasa.exceptions import TimeoutException, UnsupportedDeviceException +from kasa.iot.device import Device, SmartDeviceException 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): @@ -300,7 +300,7 @@ async def discover_single( 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 +382,7 @@ async def discover_single( 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 +397,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: 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) -> Device: """Get SmartDevice from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) @@ -423,7 +423,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevic def _get_device_instance( data: bytes, config: DeviceConfig, - ) -> SmartDevice: + ) -> Device: """Get SmartDevice from the new 20002 response.""" try: info = json_loads(data[16:]) diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py new file mode 100644 index 000000000..a54306b52 --- /dev/null +++ b/kasa/iot/__init__.py @@ -0,0 +1,9 @@ +"""Package for supporting legacy kasa devices.""" +from .bulb import Bulb +from .device import Device +from .dimmer import Dimmer +from .lightstrip import LightStrip +from .plug import Plug +from .strip import Strip + +__all__ = ["Device", "Plug", "Bulb", "Strip", "Dimmer", "LightStrip"] diff --git a/kasa/smartbulb.py b/kasa/iot/bulb.py similarity index 94% rename from kasa/smartbulb.py rename to kasa/iot/bulb.py index 5b5ae573f..a28dc35b2 100644 --- a/kasa/smartbulb.py +++ b/kasa/iot/bulb.py @@ -9,10 +9,11 @@ except ImportError: from pydantic import BaseModel, Field, root_validator -from .deviceconfig import DeviceConfig -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol -from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update +from kasa.iot.modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage + +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .device import Device, DeviceType, SmartDeviceException, requires_update class ColorTempRange(NamedTuple): @@ -30,7 +31,7 @@ class HSV(NamedTuple): value: int -class SmartBulbPreset(BaseModel): +class BulbPreset(BaseModel): """Bulb configuration preset.""" index: int @@ -116,7 +117,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class SmartBulb(SmartDevice): +class Bulb(Device): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -132,7 +133,7 @@ class SmartBulb(SmartDevice): Examples: >>> import asyncio - >>> bulb = SmartBulb("127.0.0.1") + >>> bulb = Bulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) Bulb2 @@ -198,7 +199,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: @@ -534,11 +535,11 @@ async def set_alias(self, alias: str) -> None: @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 diff --git a/kasa/smartdevice.py b/kasa/iot/device.py similarity index 97% rename from kasa/smartdevice.py rename to kasa/iot/device.py index 01ca382dc..a5aefcba9 100755 --- a/kasa/smartdevice.py +++ b/kasa/iot/device.py @@ -19,15 +19,16 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, 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 ..credentials import Credentials +from ..device import Device as BaseDevice +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 from .modules import Emeter, Module -from .protocol import BaseProtocol -from .xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -92,7 +93,7 @@ def _parse_features(features: str) -> Set[str]: return set(features.split(":")) -class SmartDevice: +class Device(BaseDevice): """Base class for all supported device types. You don't usually want to initialize this class manually, @@ -115,7 +116,7 @@ class SmartDevice: Examples: >>> import asyncio - >>> dev = SmartDevice("127.0.0.1") + >>> dev = Device("127.0.0.1") >>> asyncio.run(dev.update()) All devices provide several informational properties: @@ -221,7 +222,7 @@ def __init__( self._features: Set[str] = set() self.modules: Dict[str, Any] = {} - self.children: List["SmartDevice"] = [] + self.children: List["Device"] = [] @property def host(self) -> str: @@ -714,7 +715,7 @@ async def _join(target, payload): ) return await _join("smartlife.iot.common.softaponboarding", payload) - def get_plug_by_name(self, name: str) -> "SmartDevice": + 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: @@ -722,7 +723,7 @@ def get_plug_by_name(self, name: str) -> "SmartDevice": raise SmartDeviceException(f"Device has no child with {name}") - def get_plug_by_index(self, index: int) -> "SmartDevice": + 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( @@ -817,7 +818,7 @@ async def connect( *, host: Optional[str] = None, config: Optional[DeviceConfig] = None, - ) -> "SmartDevice": + ) -> "Device": """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -834,6 +835,6 @@ async def connect( :rtype: SmartDevice :return: Object for querying/controlling found device. """ - from .device_factory import connect # pylint: disable=import-outside-toplevel + from ..device_factory import connect # pylint: disable=import-outside-toplevel return await connect(host=host, config=config) # type: ignore[arg-type] diff --git a/kasa/smartdimmer.py b/kasa/iot/dimmer.py similarity index 96% rename from kasa/smartdimmer.py rename to kasa/iot/dimmer.py index 97738cc43..f91d84e17 100644 --- a/kasa/smartdimmer.py +++ b/kasa/iot/dimmer.py @@ -3,10 +3,10 @@ from typing import Any, Dict, Optional from kasa.deviceconfig import DeviceConfig -from kasa.modules import AmbientLight, Motion +from kasa.iot.device import DeviceType, SmartDeviceException, requires_update +from kasa.iot.modules import AmbientLight, Motion +from kasa.iot.plug import Plug from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update -from kasa.smartplug import SmartPlug class ButtonAction(Enum): @@ -32,7 +32,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class SmartDimmer(SmartPlug): +class Dimmer(Plug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -50,7 +50,7 @@ class SmartDimmer(SmartPlug): Examples: >>> import asyncio - >>> dimmer = SmartDimmer("192.168.1.105") + >>> dimmer = Dimmer("192.168.1.105") >>> asyncio.run(dimmer.turn_on()) >>> dimmer.brightness 25 diff --git a/kasa/smartlightstrip.py b/kasa/iot/lightstrip.py similarity index 92% rename from kasa/smartlightstrip.py rename to kasa/iot/lightstrip.py index 103ecfa88..994aabc7a 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/iot/lightstrip.py @@ -1,14 +1,14 @@ """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 ..deviceconfig import DeviceConfig +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..protocol import BaseProtocol +from .bulb import Bulb +from .device import DeviceType, SmartDeviceException, requires_update -class SmartLightStrip(SmartBulb): +class LightStrip(Bulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -17,7 +17,7 @@ class SmartLightStrip(SmartBulb): Examples: >>> import asyncio - >>> strip = SmartLightStrip("127.0.0.1") + >>> strip = LightStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> print(strip.alias) KL430 pantry lightstrip diff --git a/kasa/modules/__init__.py b/kasa/iot/modules/__init__.py similarity index 100% rename from kasa/modules/__init__.py rename to kasa/iot/modules/__init__.py diff --git a/kasa/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py similarity index 100% rename from kasa/modules/ambientlight.py rename to kasa/iot/modules/ambientlight.py diff --git a/kasa/modules/antitheft.py b/kasa/iot/modules/antitheft.py similarity index 100% rename from kasa/modules/antitheft.py rename to kasa/iot/modules/antitheft.py diff --git a/kasa/modules/cloud.py b/kasa/iot/modules/cloud.py similarity index 100% rename from kasa/modules/cloud.py rename to kasa/iot/modules/cloud.py diff --git a/kasa/modules/countdown.py b/kasa/iot/modules/countdown.py similarity index 100% rename from kasa/modules/countdown.py rename to kasa/iot/modules/countdown.py diff --git a/kasa/modules/emeter.py b/kasa/iot/modules/emeter.py similarity index 98% rename from kasa/modules/emeter.py rename to kasa/iot/modules/emeter.py index 11eed48f8..1570519eb 100644 --- a/kasa/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -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 diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py new file mode 100644 index 000000000..639ea1ff6 --- /dev/null +++ b/kasa/iot/modules/module.py @@ -0,0 +1,88 @@ +"""Base class for all module implementations.""" +import collections +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from ...exceptions import SmartDeviceException +from ...module import Module as BaseModule + +if TYPE_CHECKING: + from kasa import Device + + +_LOGGER = logging.getLogger(__name__) + + +# TODO: This is used for query construcing +def merge(d, u): + """Update dict recursively.""" + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = merge(d.get(k, {}), v) + else: + d[k] = v + return d + + +class Module(BaseModule, 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: "Device", module: str): + self._device: "Device" = device + self._module = module + + @abstractmethod + def query(self): + """Query to execute during the update cycle. + + The inheriting modules implement this to include their wanted + queries to the query that gets executed when Device.update() gets called. + """ + + @property + def estimated_query_response_size(self): + """Estimated maximum size of query response. + + The inheriting modules implement this to estimate how large a query response + will be so that queries can be split should an estimated response be too large + """ + return 256 # Estimate for modules that don't specify + + @property + def data(self): + """Return the module specific raw data from the last update.""" + if self._module not in self._device._last_update: + raise SmartDeviceException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + return self._device._last_update[self._module] + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + if self._module not in self._device._last_update: + _LOGGER.debug("Initial update, so consider supported: %s", self._module) + return True + + return "err_code" not in self.data + + def call(self, method, params=None): + """Call the given method with the given parameters.""" + return self._device._query_helper(self._module, method, params) + + def query_for_command(self, query, params=None): + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/kasa/modules/motion.py b/kasa/iot/modules/motion.py similarity index 97% rename from kasa/modules/motion.py rename to kasa/iot/modules/motion.py index 71d1a617b..2c0b71d86 100644 --- a/kasa/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from ..exceptions import SmartDeviceException +from ...exceptions import SmartDeviceException from .module import Module diff --git a/kasa/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py similarity index 100% rename from kasa/modules/rulemodule.py rename to kasa/iot/modules/rulemodule.py diff --git a/kasa/modules/schedule.py b/kasa/iot/modules/schedule.py similarity index 100% rename from kasa/modules/schedule.py rename to kasa/iot/modules/schedule.py diff --git a/kasa/modules/time.py b/kasa/iot/modules/time.py similarity index 96% rename from kasa/modules/time.py rename to kasa/iot/modules/time.py index d72e2d600..726d0c1b1 100644 --- a/kasa/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,7 +1,7 @@ """Provides the current time and timezone information.""" from datetime import datetime -from ..exceptions import SmartDeviceException +from ...exceptions import SmartDeviceException from .module import Module, merge diff --git a/kasa/modules/usage.py b/kasa/iot/modules/usage.py similarity index 100% rename from kasa/modules/usage.py rename to kasa/iot/modules/usage.py diff --git a/kasa/smartplug.py b/kasa/iot/plug.py similarity index 93% rename from kasa/smartplug.py rename to kasa/iot/plug.py index e8251b689..6eab21572 100644 --- a/kasa/smartplug.py +++ b/kasa/iot/plug.py @@ -3,14 +3,14 @@ from typing import Any, Dict, Optional from kasa.deviceconfig import DeviceConfig -from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage +from kasa.iot.device import Device, DeviceType, requires_update +from kasa.iot.modules import Antitheft, Cloud, Schedule, Time, Usage from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) -class SmartPlug(SmartDevice): +class Plug(Device): r"""Representation of a TP-Link Smart Switch. To initialize, you have to await :func:`update()` at least once. @@ -25,7 +25,7 @@ class SmartPlug(SmartDevice): Examples: >>> import asyncio - >>> plug = SmartPlug("127.0.0.1") + >>> plug = Plug("127.0.0.1") >>> asyncio.run(plug.update()) >>> plug.alias Kitchen diff --git a/kasa/smartstrip.py b/kasa/iot/strip.py similarity index 96% rename from kasa/smartstrip.py rename to kasa/iot/strip.py index b1e967c45..7eb8966e9 100755 --- a/kasa/smartstrip.py +++ b/kasa/iot/strip.py @@ -4,19 +4,19 @@ from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional -from kasa.smartdevice import ( +from kasa.iot.device import ( + Device, DeviceType, EmeterStatus, - SmartDevice, SmartDeviceException, merge, requires_update, ) -from kasa.smartplug import SmartPlug +from kasa.iot.modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage +from kasa.iot.plug import Plug -from .deviceconfig import DeviceConfig -from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ def merge_sums(dicts): return total_dict -class SmartStrip(SmartDevice): +class Strip(Device): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. @@ -49,7 +49,7 @@ class SmartStrip(SmartDevice): Examples: >>> import asyncio - >>> strip = SmartStrip("127.0.0.1") + >>> strip = Strip("127.0.0.1") >>> asyncio.run(strip.update()) >>> strip.alias TP-LINK_Power Strip_CF69 @@ -244,7 +244,7 @@ def emeter_realtime(self) -> EmeterStatus: return EmeterStatus(emeter) -class SmartStripPlug(SmartPlug): +class SmartStripPlug(Plug): """Representation of a single socket in a power strip. This allows you to use the sockets as they were SmartPlug objects. @@ -254,7 +254,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: "Strip", child_id: str) -> None: super().__init__(host) self.parent = parent diff --git a/kasa/module.py b/kasa/module.py new file mode 100644 index 000000000..5d8366227 --- /dev/null +++ b/kasa/module.py @@ -0,0 +1,8 @@ +"""Module for Module base class.""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +class Module: + """Placeholder for interface or base class.""" diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py new file mode 100644 index 000000000..48fb410c7 --- /dev/null +++ b/kasa/smart/__init__.py @@ -0,0 +1,6 @@ +"""Package for supporting tapo-branded and newer kasa devices.""" +from .bulb import Bulb +from .device import Device +from .plug import Plug + +__all__ = ["Device", "Plug", "Bulb"] diff --git a/kasa/tapo/tapobulb.py b/kasa/smart/bulb.py similarity index 96% rename from kasa/tapo/tapobulb.py rename to kasa/smart/bulb.py index bbaf093d6..2ace7d473 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/smart/bulb.py @@ -2,8 +2,9 @@ from typing import Any, Dict, List, Optional from ..exceptions import SmartDeviceException -from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset -from .tapodevice import TapoDevice +from ..iot.bulb import HSV, BulbPreset, ColorTempRange +from ..iot.bulb import Bulb as IotBulb +from .device import Device AVAILABLE_EFFECTS = { "L1": "Party", @@ -11,10 +12,10 @@ } -class TapoBulb(TapoDevice, SmartBulb): +class Bulb(Device, IotBulb): """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. """ @property @@ -256,6 +257,6 @@ def state_information(self) -> Dict[str, Any]: return info @property - def presets(self) -> List[SmartBulbPreset]: + def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/tapo/tapodevice.py b/kasa/smart/device.py similarity index 98% rename from kasa/tapo/tapodevice.py rename to kasa/smart/device.py index 86967b69d..5b105408d 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/smart/device.py @@ -8,15 +8,16 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException -from ..modules import Emeter +from ..iot.device import Device as IotDevice +from ..iot.device import WifiNetwork from ..protocol import BaseProtocol -from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol +from .modules.emeter import Emeter _LOGGER = logging.getLogger(__name__) -class TapoDevice(SmartDevice): +class Device(IotDevice): """Base class to represent a TAPO device.""" def __init__( diff --git a/kasa/smart/modules/emeter.py b/kasa/smart/modules/emeter.py new file mode 100644 index 000000000..1570519eb --- /dev/null +++ b/kasa/smart/modules/emeter.py @@ -0,0 +1,111 @@ +"""Implementation of the emeter module.""" +from datetime import datetime +from typing import Dict, List, Optional, Union + +from ...emeterstatus import EmeterStatus +from .usage import Usage + + +class Emeter(Usage): + """Emeter module.""" + + @property # type: ignore + def realtime(self) -> EmeterStatus: + """Return current energy readings.""" + return EmeterStatus(self.data["get_realtime"]) + + @property + def emeter_today(self) -> Optional[float]: + """Return today's energy consumption in kWh.""" + raw_data = self.daily_data + today = datetime.now().day + data = self._convert_stat_data(raw_data, entry_key="day", key=today) + return data.get(today) + + @property + def emeter_this_month(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + raw_data = self.monthly_data + current_month = datetime.now().month + data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) + return data.get(current_month) + + async def erase_stats(self): + """Erase all stats. + + Uses different query than usage meter. + """ + return await self.call("erase_emeter_stat") + + async def get_realtime(self): + """Return real-time statistics.""" + return await self.call("get_realtime") + + async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) + return data + + async def get_monthstat(self, *, year=None, kwh=True) -> Dict: + """Return monthly stats for the given year. + + The return value is a dictionary of {month: energy, ...}. + """ + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) + return data + + def _convert_stat_data( + self, + data: List[Dict[str, Union[int, float]]], + entry_key: str, + kwh: bool = True, + key: Optional[int] = None, + ) -> Dict[Union[int, float], Union[int, float]]: + """Return emeter information keyed with the day/month. + + The incoming data is a list of dictionaries:: + + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'energy_wh': int, <-- for emeter in some versions (wh) + 'energy': float <-- for emeter in other versions (kwh) + }, ...] + + :return: a dictionary keyed by day or month with energy as the value. + """ + if not data: + return {} + + scale: float = 1 + + if "energy_wh" in data[0]: + value_key = "energy_wh" + if kwh: + scale = 1 / 1000 + else: + value_key = "energy" + if not kwh: + scale = 1000 + + if key is None: + # Return all the data + return {entry[entry_key]: entry[value_key] * scale for entry in data} + + # In this case we want a specific key in the data + # i.e. the current day or month. + # + # Since we usually want the data at the end of the list so we can + # optimize the search by starting at the end and avoid scaling + # the data we don't need. + # + for entry in reversed(data): + if entry[entry_key] == key: + return {entry[entry_key]: entry[value_key] * scale} + + return {} diff --git a/kasa/modules/module.py b/kasa/smart/modules/module.py similarity index 93% rename from kasa/modules/module.py rename to kasa/smart/modules/module.py index 40890f297..1bfcc496c 100644 --- a/kasa/modules/module.py +++ b/kasa/smart/modules/module.py @@ -4,10 +4,10 @@ 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 import Device _LOGGER = logging.getLogger(__name__) @@ -31,8 +31,8 @@ class Module(ABC): executed during the regular update cycle. """ - def __init__(self, device: "SmartDevice", module: str): - self._device: "SmartDevice" = device + def __init__(self, device: "Device", module: str): + self._device: "Device" = device self._module = module @abstractmethod diff --git a/kasa/smart/modules/usage.py b/kasa/smart/modules/usage.py new file mode 100644 index 000000000..10b9689d3 --- /dev/null +++ b/kasa/smart/modules/usage.py @@ -0,0 +1,116 @@ +"""Implementation of the usage interface.""" +from datetime import datetime +from typing import Dict + +from .module import Module, merge + + +class Usage(Module): + """Baseclass for emeter/usage interfaces.""" + + def query(self): + """Return the base query.""" + now = datetime.now() + year = now.year + month = now.month + + req = self.query_for_command("get_realtime") + req = merge( + req, self.query_for_command("get_daystat", {"year": year, "month": month}) + ) + req = merge(req, self.query_for_command("get_monthstat", {"year": year})) + + return req + + @property + def estimated_query_response_size(self): + """Estimated maximum query response size.""" + return 2048 + + @property + def daily_data(self): + """Return statistics on daily basis.""" + return self.data["get_daystat"]["day_list"] + + @property + def monthly_data(self): + """Return statistics on monthly basis.""" + return self.data["get_monthstat"]["month_list"] + + @property + def usage_today(self): + """Return today's usage in minutes.""" + today = datetime.now().day + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.daily_data): + if entry["day"] == today: + return entry["time"] + return None + + @property + def usage_this_month(self): + """Return usage in this month in minutes.""" + this_month = datetime.now().month + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.monthly_data): + if entry["month"] == this_month: + return entry["time"] + return None + + async def get_raw_daystat(self, *, year=None, month=None) -> Dict: + """Return raw daily stats for the given year & month.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + + return await self.call("get_daystat", {"year": year, "month": month}) + + async def get_raw_monthstat(self, *, year=None) -> Dict: + """Return raw monthly stats for the given year.""" + if year is None: + year = datetime.now().year + + return await self.call("get_monthstat", {"year": year}) + + async def get_daystat(self, *, year=None, month=None) -> Dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: time, ...}. + """ + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day") + return data + + async def get_monthstat(self, *, year=None) -> Dict: + """Return monthly stats for the given year. + + The return value is a dictionary of {month: time, ...}. + """ + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month") + return data + + async def erase_stats(self): + """Erase all stats.""" + return await self.call("erase_runtime_stat") + + def _convert_stat_data(self, data, entry_key) -> Dict: + """Return usage information keyed with the day/month. + + The incoming data is a list of dictionaries:: + + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'time': int, <-- for usage (mins) + }, ...] + + :return: return a dictionary keyed by day or month with time as the value. + """ + if not data: + return {} + + data = {entry[entry_key]: entry["time"] for entry in data} + + return data diff --git a/kasa/tapo/tapoplug.py b/kasa/smart/plug.py similarity index 93% rename from kasa/tapo/tapoplug.py rename to kasa/smart/plug.py index 1bd90fd37..e0d6a023e 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/smart/plug.py @@ -4,14 +4,14 @@ from typing import Any, Dict, Optional, cast from ..deviceconfig import DeviceConfig +from ..iot.device import DeviceType from ..protocol import BaseProtocol -from ..smartdevice import DeviceType -from .tapodevice import TapoDevice +from .device import Device _LOGGER = logging.getLogger(__name__) -class TapoPlug(TapoDevice): +class Plug(Device): """Class to represent a TAPO Plug.""" def __init__( diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py deleted file mode 100644 index eeb3670cf..000000000 --- a/kasa/tapo/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Package for supporting tapo-branded and newer kasa devices.""" -from .tapobulb import TapoBulb -from .tapodevice import TapoDevice -from .tapoplug import TapoPlug - -__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 24bc3372b..a95f3f60c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -11,17 +11,17 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 +import kasa.smart as Smart from kasa import ( + Bulb, Credentials, + Device, + Dimmer, Discover, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, + LightStrip, + Plug, + Strip, ) -from kasa.tapo import TapoBulb, TapoDevice, TapoPlug from kasa.xortransport import XorEncryption from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -331,34 +331,34 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return TapoPlug + return Smart.Plug for d in BULBS_SMART: if d in model: - return TapoBulb + return Smart.Bulb for d in DIMMERS_SMART: if d in model: - return TapoBulb + return Smart.Bulb else: for d in STRIPS_IOT: if d in model: - return SmartStrip + return Strip for d in PLUGS_IOT: if d in model: - return SmartPlug + return Plug # 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 LightStrip for d in BULBS_IOT: if d in model: - return SmartBulb + return Bulb for d in DIMMERS_IOT: if d in model: - return SmartDimmer + return Dimmer raise Exception("Unable to find type for %s", model) @@ -424,11 +424,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 diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 0676022ba..b5a530826 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,6 +1,6 @@ import pytest -from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException +from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException from .conftest import ( bulb, @@ -20,7 +20,7 @@ @bulb -async def test_bulb_sysinfo(dev: SmartBulb): +async def test_bulb_sysinfo(dev: Bulb): assert dev.sys_info is not None BULB_SCHEMA(dev.sys_info) @@ -33,7 +33,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 @@ -42,7 +42,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: Bulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None @@ -51,13 +51,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: Bulb): 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 @@ -76,8 +76,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: Bulb, mocker): + set_light_state = mocker.patch("kasa.iot.Bulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) set_light_state.assert_called_with( @@ -88,7 +88,7 @@ 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 @@ -106,13 +106,13 @@ async def test_invalid_hsv(dev: SmartBulb, turn_on): @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): @@ -122,7 +122,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 @@ -134,7 +134,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() @@ -142,15 +142,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: Bulb, mocker): + set_light_state = mocker.patch("kasa.iot.Bulb.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: Bulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") assert dev.valid_temperature_range == (2700, 5000) @@ -158,7 +158,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): @@ -166,7 +166,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) @@ -179,7 +179,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 @@ -196,8 +196,8 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on): @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: Bulb, mocker): + set_light_state = mocker.patch("kasa.iot.Bulb.set_light_state") await dev.turn_on(transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000) @@ -208,15 +208,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: Bulb, mocker): + set_light_state = mocker.patch("kasa.iot.Bulb.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): @@ -227,7 +227,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): @@ -238,9 +238,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: Bulb, mocker ): - query_helper = mocker.patch("kasa.SmartBulb._query_helper") + query_helper = mocker.patch("kasa.iot.Bulb._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] @@ -252,7 +252,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: Bulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -265,7 +265,7 @@ async def test_list_presets(dev: SmartBulb): @bulb_iot -async def test_modify_preset(dev: SmartBulb, mocker): +async def test_modify_preset(dev: Bulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: pytest.skip("Some strips do not support presets") @@ -277,7 +277,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 @@ -290,7 +290,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) ) @@ -299,20 +299,20 @@ 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: Bulb, 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.Bulb._query_helper") await dev.save_preset(preset) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 14dbb4bdb..c98a1da9a 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -8,8 +8,8 @@ from kasa import ( AuthenticationException, Credentials, + Device, EmeterStatus, - SmartDevice, SmartDeviceException, UnsupportedDeviceException, ) @@ -109,9 +109,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 Device - if isinstance(dev, TapoDevice): + if isinstance(dev, Device): params = ["na", "get_device_info"] else: params = ["system", "get_sysinfo"] @@ -218,7 +218,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) @@ -281,7 +281,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() @@ -294,10 +294,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}" @@ -515,10 +515,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 diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 9a068cd99..f854d232f 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -6,15 +6,15 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + Bulb, Credentials, + Device, DeviceType, + Dimmer, Discover, - SmartBulb, - SmartDevice, + LightStrip, + Plug, SmartDeviceException, - SmartDimmer, - SmartLightStrip, - SmartPlug, ) from kasa.device_factory import connect, get_protocol from kasa.deviceconfig import ( @@ -89,7 +89,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 diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py index da1707dc7..35d395e37 100644 --- a/kasa/tests/test_device_type.py +++ b/kasa/tests/test_device_type.py @@ -1,4 +1,4 @@ -from kasa.smartdevice import DeviceType +from kasa.iot.device import DeviceType async def test_device_type_from_value(): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index b5e98b787..644779053 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -1,6 +1,6 @@ import pytest -from kasa import SmartDimmer +from kasa import Dimmer 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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index db4d8fc1c..1c7639e7d 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -11,9 +11,9 @@ from kasa import ( Credentials, + Device, DeviceType, Discover, - SmartDevice, SmartDeviceException, protocol, ) @@ -52,14 +52,14 @@ @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: @@ -68,21 +68,21 @@ async def test_type_detection_bulb(dev: SmartDevice): @strip -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 @@ -108,7 +108,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 @@ -141,7 +141,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 @@ -229,7 +229,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 @@ -295,7 +295,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 = Device("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" @@ -320,7 +320,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) @@ -338,7 +338,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: diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index fdb219b5f..23503dc7e 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -4,7 +4,7 @@ import pytest from kasa import EmeterStatus, SmartDeviceException -from kasa.modules.emeter import Emeter +from kasa.iot.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter from .newfakes import CURRENT_CONSUMPTION_SCHEMA diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 109b9d7c3..aae1596e9 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,27 +1,27 @@ import pytest -from kasa import DeviceType, SmartLightStrip +from kasa import DeviceType, LightStrip from kasa.exceptions import SmartDeviceException from .conftest import lightstrip @lightstrip -async def test_lightstrip_length(dev: SmartLightStrip): +async def test_lightstrip_length(dev: LightStrip): 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: LightStrip): 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: LightStrip): with pytest.raises(SmartDeviceException): await dev.set_effect("Not real") @@ -33,9 +33,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: LightStrip, brightness, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.LightStrip._query_helper") # test that default brightness works (100 for candy cane) if brightness == 100: @@ -51,9 +51,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: LightStrip, transition, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.LightStrip._query_helper") # test that default (500 for candy cane) transition works if transition == 500: @@ -67,6 +67,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: LightStrip): assert dev.has_effects is True assert dev.effect_list diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 5772ba42c..947ecb3f2 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -10,54 +10,54 @@ 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.bulb.Bulb", return_value=p) + mocker.patch("kasa.iot.bulb.Bulb.update") + res = xdoctest.doctest_module("kasa.iot.bulb", "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.device.Device", return_value=p) + mocker.patch("kasa.iot.device.Device.update") + res = xdoctest.doctest_module("kasa.iot.device", "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.plug.Plug", return_value=p) + mocker.patch("kasa.iot.plug.Plug.update") + res = xdoctest.doctest_module("kasa.iot.plug", "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.strip.Strip", return_value=p) + mocker.patch("kasa.iot.strip.Strip.update") + res = xdoctest.doctest_module("kasa.iot.strip", "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.dimmer.Dimmer", return_value=p) + mocker.patch("kasa.iot.dimmer.Dimmer.update") + res = xdoctest.doctest_module("kasa.iot.dimmer", "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.lightstrip.LightStrip", return_value=p) + mocker.patch("kasa.iot.lightstrip.LightStrip.update") + res = xdoctest.doctest_module("kasa.iot.lightstrip", "all") assert not res["failed"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index b2ae9c33f..c5fc96933 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -5,7 +5,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 import kasa -from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException +from kasa import Credentials, Device, DeviceConfig, SmartDeviceException from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -16,7 +16,7 @@ for (mn, dc) in inspect.getmembers( kasa, lambda member: inspect.isclass(member) - and (member == SmartDevice or issubclass(member, SmartDevice)), + and (member == Device or issubclass(member, Device)), ) ] @@ -222,7 +222,7 @@ def test_device_class_ctors(device_class): @device_iot -async def test_modules_preserved(dev: SmartDevice): +async def test_modules_preserved(dev: Device): """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() @@ -232,7 +232,7 @@ 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 = SmartDevice(host, config=DeviceConfig(host, timeout=100)) + dev = Device(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -246,7 +246,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( @@ -256,7 +256,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: SmartDevice): +async def test_modules_not_supported(dev: Device): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 451b7e34e..b79217354 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,7 +2,7 @@ import pytest -from kasa import SmartDeviceException, SmartStrip +from kasa import SmartDeviceException, Strip from .conftest import handle_turn_on, strip, turn_on @@ -68,7 +68,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: Strip): name = dev.children[0].alias assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] @@ -77,7 +77,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: Strip): assert dev.get_plug_by_index(0) == dev.children[0] with pytest.raises(SmartDeviceException): diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 61672ffb7..8e689379f 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -3,7 +3,7 @@ import pytest -from kasa.modules import Usage +from kasa.iot.modules import Usage def test_usage_convert_stat_data(): From 5f7ab52858c5bb6153acf2238bb23351e02a644a Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 28 Jan 2024 11:15:56 +0000 Subject: [PATCH 02/15] Tweak and add tests --- kasa/__init__.py | 15 ++------ kasa/cli.py | 62 +++++++++++++++---------------- kasa/device.py | 29 +++++++++++++++ kasa/iot/device.py | 26 ------------- kasa/iot/modules/module.py | 8 +++- kasa/smart/modules/module.py | 11 ++++-- kasa/tests/conftest.py | 16 +++----- kasa/tests/test_bulb.py | 3 +- kasa/tests/test_device_factory.py | 11 ++++-- kasa/tests/test_dimmer.py | 2 +- kasa/tests/test_discovery.py | 5 ++- kasa/tests/test_lightstrip.py | 3 +- kasa/tests/test_smartdevice.py | 56 ++++++++++++++++++++-------- kasa/tests/test_strip.py | 3 +- 14 files changed, 142 insertions(+), 108 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 7b3e8057b..f1945b1c0 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -15,6 +15,7 @@ from warnings import warn from kasa.credentials import Credentials +from kasa.device import Device from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -29,12 +30,8 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.iot.bulb import Bulb, BulbPreset, TurnOnBehavior, TurnOnBehaviors -from kasa.iot.device import Device, DeviceType -from kasa.iot.dimmer import Dimmer -from kasa.iot.lightstrip import LightStrip -from kasa.iot.plug import Plug -from kasa.iot.strip import Strip +from kasa.iot.bulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors +from kasa.iot.device import DeviceType from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -50,7 +47,6 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "Bulb", "BulbPreset", "TurnOnBehaviors", "TurnOnBehavior", @@ -58,10 +54,6 @@ "EmeterStatus", "Device", "SmartDeviceException", - "Plug", - "Strip", - "Dimmer", - "LightStrip", "AuthenticationException", "UnsupportedDeviceException", "TimeoutException", @@ -82,6 +74,7 @@ "SmartLightStrip": Iot.LightStrip, "SmartStrip": Iot.Strip, "SmartDimmer": Iot.Dimmer, + "SmartBulbPreset": Iot.bulb.BulbPreset, } diff --git a/kasa/cli.py b/kasa/cli.py index e9a726dc5..14dec3b2f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,20 +13,16 @@ from kasa import ( AuthenticationException, - Bulb, ConnectionType, Credentials, Device, DeviceConfig, DeviceFamilyType, - Dimmer, Discover, EncryptType, - LightStrip, - Plug, - Strip, UnsupportedDeviceException, ) +from kasa import iot as Iot from kasa.discover import DiscoveryResult try: @@ -62,11 +58,11 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": Plug, - "bulb": Bulb, - "dimmer": Dimmer, - "strip": Strip, - "lightstrip": LightStrip, + "plug": Iot.Plug, + "bulb": Iot.Bulb, + "dimmer": Iot.Dimmer, + "strip": Iot.Strip, + "lightstrip": Iot.LightStrip, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -80,7 +76,7 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(Device) +pass_dev = click.make_pass_decorator(Iot.Device) class ExceptionHandlerGroup(click.Group): @@ -111,7 +107,7 @@ def to_serializable(val): return str(val) @to_serializable.register(Device) - def _device_to_serializable(val: Device): + def _device_to_serializable(val: Iot.Device): """Serialize smart device data, just using the last update raw payload.""" return val.internal_state @@ -383,7 +379,7 @@ async def scan(dev): @click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) @pass_dev -async def join(dev: Device, ssid: str, password: str, keytype: str): +async def join(dev: Iot.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) @@ -427,7 +423,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - async def print_discovered(dev: Device): + async def print_discovered(dev: Iot.Device): async with sem: try: await dev.update() @@ -521,7 +517,7 @@ async def sysinfo(dev): @cli.command() @pass_dev @click.pass_context -async def state(ctx, dev: Device): +async def state(ctx, dev: Iot.Device): """Print out device state and versions.""" verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False @@ -584,7 +580,7 @@ async def alias(dev, new_alias, index): if not dev.is_strip: echo("Index can only used for power strips!") return - dev = cast(Strip, dev) + dev = cast(Iot.Strip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -606,7 +602,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: Device, module, command, parameters): +async def raw_command(ctx, dev: Iot.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) @@ -617,7 +613,7 @@ async def raw_command(ctx, dev: Device, 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: Device, module, command, parameters): +async def cmd_command(dev: Iot.Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) @@ -634,7 +630,7 @@ async def cmd_command(dev: Device, 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: Device, index: int, name: str, year, month, erase): +async def emeter(dev: Iot.Device, index: int, name: str, year, month, erase): """Query emeter for historical consumption. Daily and monthly data provided in CSV format. @@ -644,7 +640,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): echo("Index and name are only for power strips!") return - dev = cast(Strip, dev) + dev = cast(Iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -696,7 +692,7 @@ async def emeter(dev: Device, 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: Device, year, month, erase): +async def usage(dev: Iot.Device, year, month, erase): """Query usage for historical consumption. Daily and monthly data provided in CSV format. @@ -734,7 +730,7 @@ async def usage(dev: Device, 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: Bulb, brightness: int, transition: int): +async def brightness(dev: Iot.Bulb, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -754,7 +750,7 @@ async def brightness(dev: Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Bulb, temperature: int, transition: int): +async def temperature(dev: Iot.Bulb, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") @@ -847,14 +843,14 @@ async def time(dev): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def on(dev: Device, index: int, name: str, transition: int): +async def on(dev: Iot.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(Strip, dev) + dev = cast(Iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -869,14 +865,14 @@ async def on(dev: Device, 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: Device, index: int, name: str, transition: int): +async def off(dev: Iot.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(Strip, dev) + dev = cast(Iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -891,14 +887,14 @@ async def off(dev: Device, 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: Device, index: int, name: str, transition: int): +async def toggle(dev: Iot.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(Strip, dev) + dev = cast(Iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -965,7 +961,7 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: Bulb): +def presets_list(dev: Iot.Bulb): """List presets.""" if not dev.is_bulb: echo("Presets only supported on bulbs") @@ -984,7 +980,9 @@ def presets_list(dev: Bulb): @click.option("--saturation", type=int) @click.option("--temperature", type=int) @pass_dev -async def presets_modify(dev: Bulb, index, brightness, hue, saturation, temperature): +async def presets_modify( + dev: Iot.Bulb, index, brightness, hue, saturation, temperature +): """Modify a preset.""" for preset in dev.presets: if preset.index == index: @@ -1012,7 +1010,7 @@ async def presets_modify(dev: Bulb, index, brightness, hue, saturation, temperat @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: Bulb, type, last, preset): +async def turn_on_behavior(dev: Iot.Bulb, type, last, preset): """Modify bulb turn-on behavior.""" settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") diff --git a/kasa/device.py b/kasa/device.py index 38b34aaa6..14f8dedd6 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -1,8 +1,37 @@ """Module for Device base class.""" import logging +from typing import Optional + +from .deviceconfig import DeviceConfig _LOGGER = logging.getLogger(__name__) class Device: """Placeholder for interface or base class.""" + + @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] diff --git a/kasa/iot/device.py b/kasa/iot/device.py index a5aefcba9..2b9b9e902 100755 --- a/kasa/iot/device.py +++ b/kasa/iot/device.py @@ -812,29 +812,3 @@ def config(self) -> DeviceConfig: 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, - ) -> "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] diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 639ea1ff6..1088019a1 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -2,13 +2,14 @@ import collections import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from ...exceptions import SmartDeviceException from ...module import Module as BaseModule if TYPE_CHECKING: from kasa import Device + from kasa.iot import Device as IotDevice _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,10 @@ class Module(BaseModule, ABC): """ def __init__(self, device: "Device", module: str): - self._device: "Device" = device + if TYPE_CHECKING: + self._device: IotDevice = cast(IotDevice, self._device) + else: + self._device = device self._module = module @abstractmethod diff --git a/kasa/smart/modules/module.py b/kasa/smart/modules/module.py index 1bfcc496c..065c3aa5f 100644 --- a/kasa/smart/modules/module.py +++ b/kasa/smart/modules/module.py @@ -2,12 +2,14 @@ import collections import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from ...exceptions import SmartDeviceException +from ...module import Module as BaseModule if TYPE_CHECKING: from kasa import Device + from kasa.smart import Device as SmartDevice _LOGGER = logging.getLogger(__name__) @@ -24,7 +26,7 @@ def merge(d, u): return d -class Module(ABC): +class Module(BaseModule, ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be @@ -32,7 +34,10 @@ class Module(ABC): """ def __init__(self, device: "Device", module: str): - self._device: "Device" = device + if TYPE_CHECKING: + self._device: SmartDevice = cast(SmartDevice, self._device) + else: + self._device = device self._module = module @abstractmethod diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index a95f3f60c..855a4a986 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -11,16 +11,12 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 +import kasa.iot as Iot import kasa.smart as Smart from kasa import ( - Bulb, Credentials, Device, - Dimmer, Discover, - LightStrip, - Plug, - Strip, ) from kasa.xortransport import XorEncryption @@ -341,24 +337,24 @@ def device_for_file(model, protocol): else: for d in STRIPS_IOT: if d in model: - return Strip + return Iot.Strip for d in PLUGS_IOT: if d in model: - return Plug + return Iot.Plug # 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 LightStrip + return Iot.LightStrip for d in BULBS_IOT: if d in model: - return Bulb + return Iot.Bulb for d in DIMMERS_IOT: if d in model: - return Dimmer + return Iot.Dimmer raise Exception("Unable to find type for %s", model) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index b5a530826..3bfd6f789 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,6 +1,7 @@ import pytest -from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException +from kasa import BulbPreset, DeviceType, SmartDeviceException +from kasa.iot import Bulb from .conftest import ( bulb, diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index f854d232f..f84c3405b 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -6,14 +6,10 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( - Bulb, Credentials, Device, DeviceType, - Dimmer, Discover, - LightStrip, - Plug, SmartDeviceException, ) from kasa.device_factory import connect, get_protocol @@ -24,6 +20,13 @@ EncryptType, ) from kasa.discover import DiscoveryResult +from kasa.iot import ( + Bulb, + Dimmer, + LightStrip, + Plug, + Strip, +) def _get_connection_type_device_class(the_fixture_data): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 644779053..8f32c1cc4 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -1,6 +1,6 @@ import pytest -from kasa import Dimmer +from kasa.iot import Dimmer from .conftest import dimmer, handle_turn_on, turn_on diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 1c7639e7d..be4c8700c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -17,6 +17,9 @@ SmartDeviceException, protocol, ) +from kasa import ( + iot as Iot, +) from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -295,7 +298,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery async def test_device_update_from_new_discovery_info(discovery_data): - device = Device("127.0.0.7") + device = Iot.Device("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index aae1596e9..485f4f311 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,7 +1,8 @@ import pytest -from kasa import DeviceType, LightStrip +from kasa import DeviceType from kasa.exceptions import SmartDeviceException +from kasa.iot import LightStrip from .conftest import lightstrip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c5fc96933..1bf6f29fe 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,4 +1,7 @@ +import importlib import inspect +import pkgutil +import sys from datetime import datetime from unittest.mock import Mock, patch @@ -6,19 +9,34 @@ import kasa from kasa import Credentials, Device, DeviceConfig, SmartDeviceException +from kasa import iot as Iot +from kasa import smart as Smart from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol -# 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 == Device or issubclass(member, Device)), - ) -] + +def _get_subclasses(of_class): + import kasa + + 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 @@ -208,21 +226,21 @@ 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) + dev = device_class_name_obj[1](host, config=config) assert dev.host == host assert dev.port == port assert dev.credentials == credentials @device_iot -async def test_modules_preserved(dev: Device): +async def test_modules_preserved(dev: Iot.Device): """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() @@ -232,7 +250,9 @@ async def test_modules_preserved(dev: Device): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" - dev = Device(host, config=DeviceConfig(host, timeout=100)) + dev = Iot.Device(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 + dev = Smart.Device(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -256,10 +276,16 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: Device): +async def test_modules_not_supported(dev: Iot.Device): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None await dev.update() for module in dev.modules.values(): assert module.is_supported is not None + + +@pytest.mark.parametrize("device_class", kasa.deprecated_smart_devices.keys()) +def test_deprecated_devices(device_class): + with pytest.deprecated_call(): + getattr(kasa, device_class) diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index b79217354..3d6b84edb 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,7 +2,8 @@ import pytest -from kasa import SmartDeviceException, Strip +from kasa import SmartDeviceException +from kasa.iot import Strip from .conftest import handle_turn_on, strip, turn_on From 963e9f5487401c3f62aa97ed64ec2ae58aa70351 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 28 Jan 2024 11:35:31 +0000 Subject: [PATCH 03/15] Fix linting --- devtools/create_module_fixtures.py | 9 ++++++--- kasa/tests/conftest.py | 2 +- kasa/tests/test_cli.py | 9 +++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index bdd4af121..cc442d9cf 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -6,15 +6,17 @@ import asyncio import json from pathlib import Path +from typing import cast import typer -from kasa import Device, Discover +from kasa import Discover +from kasa import iot as Iot app = typer.Typer() -def create_fixtures(dev: Device, outputdir: Path): +def create_fixtures(dev: Iot.Device, 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: Device = asyncio.run(Discover.discover_single(host)) + dev: Iot.Device = cast(Iot.Device, 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(Iot.Device, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 855a4a986..940896d2e 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -6,7 +6,7 @@ from json import dumps as json_dumps from os.path import basename from pathlib import Path, PurePath -from typing import Dict, Optional, Set +from typing import Dict, Optional, Set, cast from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index c98a1da9a..7007a58fb 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -13,6 +13,7 @@ SmartDeviceException, UnsupportedDeviceException, ) +from kasa import iot as Iot from kasa.cli import ( TYPE_TO_CLASS, alias, @@ -218,7 +219,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: Device, mocker): +async def test_emeter(dev: Iot.Device, mocker): runner = CliRunner() res = await runner.invoke(emeter, obj=dev) @@ -281,7 +282,7 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: Device, mocker): +async def test_json_output(dev: Iot.Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) runner = CliRunner() @@ -297,7 +298,7 @@ async def test_credentials(discovery_mock, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: Device): + async def _state(dev: Iot.Device): if dev.credentials: click.echo( f"Username:{dev.credentials.username} Password:{dev.credentials.password}" @@ -518,7 +519,7 @@ async def test_type_param(device_type, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: Device): + async def _state(dev: Iot.Device): nonlocal result_device result_device = dev From 43cc26ba5f270187254cd45d42fbf5e2418104ee Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 29 Jan 2024 10:24:59 +0000 Subject: [PATCH 04/15] Remove duplicate implementations affecting project coverage --- devtools/create_module_fixtures.py | 9 +-- devtools/dump_devinfo.py | 12 +-- kasa/__init__.py | 16 ++-- kasa/cli.py | 56 +++++++------- kasa/device_factory.py | 35 +++++---- kasa/smart/device.py | 2 +- kasa/smart/modules/emeter.py | 111 +-------------------------- kasa/smart/modules/module.py | 92 +---------------------- kasa/smart/modules/usage.py | 116 ----------------------------- kasa/tests/conftest.py | 20 ++--- kasa/tests/test_cli.py | 10 +-- kasa/tests/test_discovery.py | 6 +- kasa/tests/test_smartdevice.py | 12 ++- 13 files changed, 92 insertions(+), 405 deletions(-) delete mode 100644 kasa/smart/modules/usage.py diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index cc442d9cf..ce9c1aa8d 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -10,13 +10,12 @@ import typer -from kasa import Discover -from kasa import iot as Iot +from kasa import Discover, iot app = typer.Typer() -def create_fixtures(dev: Iot.Device, outputdir: Path): +def create_fixtures(dev: iot.Device, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): module_dir = outputdir / name @@ -45,14 +44,14 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: Iot.Device = cast(Iot.Device, asyncio.run(Discover.discover_single(host))) + dev: iot.Device = cast(iot.Device, 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(Iot.Device, dev) + dev = cast(iot.Device, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 4d73b5216..5a6788ae3 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -19,8 +19,8 @@ import asyncclick as click -import kasa.iot as Iot -import kasa.smart as Smart +import kasa.iot as iot +import kasa.smart as smart from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest from kasa import ( AuthenticationException, @@ -113,9 +113,9 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: Iot.Device, batch_size: int): +async def handle_device(basedir, autosave, device: iot.Device, batch_size: int): """Create a fixture for a single device instance.""" - if isinstance(device, Smart.Device): + if isinstance(device, smart.Device): filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -268,7 +268,7 @@ def _echo_error(msg: str): async def _make_requests_or_exit( - device: Smart.Device, + device: smart.Device, requests: List[SmartRequest], name: str, batch_size: int, @@ -313,7 +313,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: Smart.Device, batch_size: int): +async def get_smart_fixture(device: smart.Device, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/__init__.py b/kasa/__init__.py index f1945b1c0..48c39c4b7 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -64,17 +64,17 @@ "DeviceFamilyType", ] -from . import iot as Iot +from . import iot deprecated_names = ["TPLinkSmartHomeProtocol"] deprecated_smart_devices = { - "SmartDevice": Iot.Device, - "SmartPlug": Iot.Plug, - "SmartBulb": Iot.Bulb, - "SmartLightStrip": Iot.LightStrip, - "SmartStrip": Iot.Strip, - "SmartDimmer": Iot.Dimmer, - "SmartBulbPreset": Iot.bulb.BulbPreset, + "SmartDevice": iot.Device, + "SmartPlug": iot.Plug, + "SmartBulb": iot.Bulb, + "SmartLightStrip": iot.LightStrip, + "SmartStrip": iot.Strip, + "SmartDimmer": iot.Dimmer, + "SmartBulbPreset": iot.bulb.BulbPreset, } diff --git a/kasa/cli.py b/kasa/cli.py index 14dec3b2f..5cf9b3957 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -21,8 +21,8 @@ Discover, EncryptType, UnsupportedDeviceException, + iot, ) -from kasa import iot as Iot from kasa.discover import DiscoveryResult try: @@ -58,11 +58,11 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": Iot.Plug, - "bulb": Iot.Bulb, - "dimmer": Iot.Dimmer, - "strip": Iot.Strip, - "lightstrip": Iot.LightStrip, + "plug": iot.Plug, + "bulb": iot.Bulb, + "dimmer": iot.Dimmer, + "strip": iot.Strip, + "lightstrip": iot.LightStrip, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -76,7 +76,7 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(Iot.Device) +pass_dev = click.make_pass_decorator(iot.Device) class ExceptionHandlerGroup(click.Group): @@ -107,7 +107,7 @@ def to_serializable(val): return str(val) @to_serializable.register(Device) - def _device_to_serializable(val: Iot.Device): + def _device_to_serializable(val: iot.Device): """Serialize smart device data, just using the last update raw payload.""" return val.internal_state @@ -379,7 +379,7 @@ async def scan(dev): @click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) @pass_dev -async def join(dev: Iot.Device, ssid: str, password: str, keytype: str): +async def join(dev: iot.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) @@ -423,7 +423,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - async def print_discovered(dev: Iot.Device): + async def print_discovered(dev: iot.Device): async with sem: try: await dev.update() @@ -517,7 +517,7 @@ async def sysinfo(dev): @cli.command() @pass_dev @click.pass_context -async def state(ctx, dev: Iot.Device): +async def state(ctx, dev: iot.Device): """Print out device state and versions.""" verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False @@ -580,7 +580,7 @@ async def alias(dev, new_alias, index): if not dev.is_strip: echo("Index can only used for power strips!") return - dev = cast(Iot.Strip, dev) + dev = cast(iot.Strip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -602,7 +602,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: Iot.Device, module, command, parameters): +async def raw_command(ctx, dev: iot.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) @@ -613,7 +613,7 @@ async def raw_command(ctx, dev: Iot.Device, 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: Iot.Device, module, command, parameters): +async def cmd_command(dev: iot.Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) @@ -630,7 +630,7 @@ async def cmd_command(dev: Iot.Device, 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: Iot.Device, index: int, name: str, year, month, erase): +async def emeter(dev: iot.Device, index: int, name: str, year, month, erase): """Query emeter for historical consumption. Daily and monthly data provided in CSV format. @@ -640,7 +640,7 @@ async def emeter(dev: Iot.Device, index: int, name: str, year, month, erase): echo("Index and name are only for power strips!") return - dev = cast(Iot.Strip, dev) + dev = cast(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -692,7 +692,7 @@ async def emeter(dev: Iot.Device, 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: Iot.Device, year, month, erase): +async def usage(dev: iot.Device, year, month, erase): """Query usage for historical consumption. Daily and monthly data provided in CSV format. @@ -730,7 +730,7 @@ async def usage(dev: Iot.Device, 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: Iot.Bulb, brightness: int, transition: int): +async def brightness(dev: iot.Bulb, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -750,7 +750,7 @@ async def brightness(dev: Iot.Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Iot.Bulb, temperature: int, transition: int): +async def temperature(dev: iot.Bulb, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") @@ -843,14 +843,14 @@ async def time(dev): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def on(dev: Iot.Device, index: int, name: str, transition: int): +async def on(dev: iot.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(Iot.Strip, dev) + dev = cast(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -865,14 +865,14 @@ async def on(dev: Iot.Device, 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: Iot.Device, index: int, name: str, transition: int): +async def off(dev: iot.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(Iot.Strip, dev) + dev = cast(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -887,14 +887,14 @@ async def off(dev: Iot.Device, 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: Iot.Device, index: int, name: str, transition: int): +async def toggle(dev: iot.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(Iot.Strip, dev) + dev = cast(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -961,7 +961,7 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: Iot.Bulb): +def presets_list(dev: iot.Bulb): """List presets.""" if not dev.is_bulb: echo("Presets only supported on bulbs") @@ -981,7 +981,7 @@ def presets_list(dev: Iot.Bulb): @click.option("--temperature", type=int) @pass_dev async def presets_modify( - dev: Iot.Bulb, index, brightness, hue, saturation, temperature + dev: iot.Bulb, index, brightness, hue, saturation, temperature ): """Modify a preset.""" for preset in dev.presets: @@ -1010,7 +1010,7 @@ 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: Iot.Bulb, type, last, preset): +async def turn_on_behavior(dev: iot.Bulb, type, last, preset): """Modify bulb turn-on behavior.""" settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 7add9d5b4..fe9f59aaf 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -3,8 +3,7 @@ import time from typing import Any, Dict, Optional, Tuple, Type -from . import iot as Iot -from . import smart as Smart +from . import iot, smart from .aestransport import AesTransport from .device import Device from .deviceconfig import DeviceConfig @@ -25,7 +24,7 @@ } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Iot.Device": +async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "iot.Device": """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -96,7 +95,7 @@ def _perf_log(has_params, perf_type): ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[Iot.Device]: +def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.Device]: """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") @@ -107,32 +106,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[Iot.Device]: raise SmartDeviceException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return Iot.Dimmer + return iot.Dimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return Iot.Strip + return iot.Strip - return Iot.Plug + return iot.Plug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return Iot.LightStrip + return iot.LightStrip - return Iot.Bulb + return iot.Bulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) -def get_device_class_from_family(device_type: str) -> Optional[Type[Iot.Device]]: +def get_device_class_from_family(device_type: str) -> Optional[Type[iot.Device]]: """Return the device class from the type name.""" - supported_device_types: Dict[str, Type[Iot.Device]] = { - "SMART.TAPOPLUG": Smart.Plug, - "SMART.TAPOBULB": Smart.Bulb, - "SMART.TAPOSWITCH": Smart.Bulb, - "SMART.KASAPLUG": Smart.Plug, - "SMART.KASASWITCH": Smart.Bulb, - "IOT.SMARTPLUGSWITCH": Iot.Plug, - "IOT.SMARTBULB": Iot.Bulb, + supported_device_types: Dict[str, Type[iot.Device]] = { + "SMART.TAPOPLUG": smart.Plug, + "SMART.TAPOBULB": smart.Bulb, + "SMART.TAPOSWITCH": smart.Bulb, + "SMART.KASAPLUG": smart.Plug, + "SMART.KASASWITCH": smart.Bulb, + "IOT.SMARTPLUGSWITCH": iot.Plug, + "IOT.SMARTBULB": iot.Bulb, } return supported_device_types.get(device_type) diff --git a/kasa/smart/device.py b/kasa/smart/device.py index 5b105408d..796769fdb 100644 --- a/kasa/smart/device.py +++ b/kasa/smart/device.py @@ -1,4 +1,4 @@ -"""Module for a TAPO device.""" +"""Module for a SMART device.""" import base64 import logging from datetime import datetime, timedelta, timezone diff --git a/kasa/smart/modules/emeter.py b/kasa/smart/modules/emeter.py index 1570519eb..deb142e2c 100644 --- a/kasa/smart/modules/emeter.py +++ b/kasa/smart/modules/emeter.py @@ -1,111 +1,6 @@ """Implementation of the emeter module.""" -from datetime import datetime -from typing import Dict, List, Optional, Union +from ...iot.modules.emeter import Emeter as IotEmeter -from ...emeterstatus import EmeterStatus -from .usage import Usage - -class Emeter(Usage): - """Emeter module.""" - - @property # type: ignore - def realtime(self) -> EmeterStatus: - """Return current energy readings.""" - return EmeterStatus(self.data["get_realtime"]) - - @property - def emeter_today(self) -> Optional[float]: - """Return today's energy consumption in kWh.""" - raw_data = self.daily_data - today = datetime.now().day - data = self._convert_stat_data(raw_data, entry_key="day", key=today) - return data.get(today) - - @property - def emeter_this_month(self) -> Optional[float]: - """Return this month's energy consumption in kWh.""" - raw_data = self.monthly_data - current_month = datetime.now().month - data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) - return data.get(current_month) - - async def erase_stats(self): - """Erase all stats. - - Uses different query than usage meter. - """ - return await self.call("erase_emeter_stat") - - async def get_realtime(self): - """Return real-time statistics.""" - return await self.call("get_realtime") - - async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: - """Return daily stats for the given year & month. - - The return value is a dictionary of {day: energy, ...}. - """ - data = await self.get_raw_daystat(year=year, month=month) - data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) - return data - - async def get_monthstat(self, *, year=None, kwh=True) -> Dict: - """Return monthly stats for the given year. - - The return value is a dictionary of {month: energy, ...}. - """ - data = await self.get_raw_monthstat(year=year) - data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) - return data - - def _convert_stat_data( - self, - data: List[Dict[str, Union[int, float]]], - entry_key: str, - kwh: bool = True, - key: Optional[int] = None, - ) -> Dict[Union[int, float], Union[int, float]]: - """Return emeter information keyed with the day/month. - - The incoming data is a list of dictionaries:: - - [{'year': int, - 'month': int, - 'day': int, <-- for get_daystat not get_monthstat - 'energy_wh': int, <-- for emeter in some versions (wh) - 'energy': float <-- for emeter in other versions (kwh) - }, ...] - - :return: a dictionary keyed by day or month with energy as the value. - """ - if not data: - return {} - - scale: float = 1 - - if "energy_wh" in data[0]: - value_key = "energy_wh" - if kwh: - scale = 1 / 1000 - else: - value_key = "energy" - if not kwh: - scale = 1000 - - if key is None: - # Return all the data - return {entry[entry_key]: entry[value_key] * scale for entry in data} - - # In this case we want a specific key in the data - # i.e. the current day or month. - # - # Since we usually want the data at the end of the list so we can - # optimize the search by starting at the end and avoid scaling - # the data we don't need. - # - for entry in reversed(data): - if entry[entry_key] == key: - return {entry[entry_key]: entry[value_key] * scale} - - return {} +class Emeter(IotEmeter): + """Placeholder for Smart Emeter module.""" diff --git a/kasa/smart/modules/module.py b/kasa/smart/modules/module.py index 065c3aa5f..aff975366 100644 --- a/kasa/smart/modules/module.py +++ b/kasa/smart/modules/module.py @@ -1,92 +1,6 @@ """Base class for all module implementations.""" -import collections -import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, cast +from ...iot.modules.module import Module as IotModule -from ...exceptions import SmartDeviceException -from ...module import Module as BaseModule -if TYPE_CHECKING: - from kasa import Device - from kasa.smart import Device as SmartDevice - - -_LOGGER = logging.getLogger(__name__) - - -# TODO: This is used for query construcing -def merge(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = merge(d.get(k, {}), v) - else: - d[k] = v - return d - - -class Module(BaseModule, 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: "Device", module: str): - if TYPE_CHECKING: - self._device: SmartDevice = cast(SmartDevice, self._device) - else: - self._device = device - self._module = module - - @abstractmethod - def query(self): - """Query to execute during the update cycle. - - The inheriting modules implement this to include their wanted - queries to the query that gets executed when Device.update() gets called. - """ - - @property - def estimated_query_response_size(self): - """Estimated maximum size of query response. - - The inheriting modules implement this to estimate how large a query response - will be so that queries can be split should an estimated response be too large - """ - return 256 # Estimate for modules that don't specify - - @property - def data(self): - """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: - raise SmartDeviceException( - f"You need to call update() prior accessing module data" - f" for '{self._module}'" - ) - - return self._device._last_update[self._module] - - @property - def is_supported(self) -> bool: - """Return whether the module is supported by the device.""" - if self._module not in self._device._last_update: - _LOGGER.debug("Initial update, so consider supported: %s", self._module) - return True - - return "err_code" not in self.data - - def call(self, method, params=None): - """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) - - def query_for_command(self, query, params=None): - """Create a request object for the given parameters.""" - return self._device._create_request(self._module, query, params) - - def __repr__(self) -> str: - return ( - f"" - ) +class Module(IotModule): # pragma: no cover + """Placeholder for Smart base module.""" diff --git a/kasa/smart/modules/usage.py b/kasa/smart/modules/usage.py deleted file mode 100644 index 10b9689d3..000000000 --- a/kasa/smart/modules/usage.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Implementation of the usage interface.""" -from datetime import datetime -from typing import Dict - -from .module import Module, merge - - -class Usage(Module): - """Baseclass for emeter/usage interfaces.""" - - def query(self): - """Return the base query.""" - now = datetime.now() - year = now.year - month = now.month - - req = self.query_for_command("get_realtime") - req = merge( - req, self.query_for_command("get_daystat", {"year": year, "month": month}) - ) - req = merge(req, self.query_for_command("get_monthstat", {"year": year})) - - return req - - @property - def estimated_query_response_size(self): - """Estimated maximum query response size.""" - return 2048 - - @property - def daily_data(self): - """Return statistics on daily basis.""" - return self.data["get_daystat"]["day_list"] - - @property - def monthly_data(self): - """Return statistics on monthly basis.""" - return self.data["get_monthstat"]["month_list"] - - @property - def usage_today(self): - """Return today's usage in minutes.""" - today = datetime.now().day - # Traverse the list in reverse order to find the latest entry. - for entry in reversed(self.daily_data): - if entry["day"] == today: - return entry["time"] - return None - - @property - def usage_this_month(self): - """Return usage in this month in minutes.""" - this_month = datetime.now().month - # Traverse the list in reverse order to find the latest entry. - for entry in reversed(self.monthly_data): - if entry["month"] == this_month: - return entry["time"] - return None - - async def get_raw_daystat(self, *, year=None, month=None) -> Dict: - """Return raw daily stats for the given year & month.""" - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - return await self.call("get_daystat", {"year": year, "month": month}) - - async def get_raw_monthstat(self, *, year=None) -> Dict: - """Return raw monthly stats for the given year.""" - if year is None: - year = datetime.now().year - - return await self.call("get_monthstat", {"year": year}) - - async def get_daystat(self, *, year=None, month=None) -> Dict: - """Return daily stats for the given year & month. - - The return value is a dictionary of {day: time, ...}. - """ - data = await self.get_raw_daystat(year=year, month=month) - data = self._convert_stat_data(data["day_list"], entry_key="day") - return data - - async def get_monthstat(self, *, year=None) -> Dict: - """Return monthly stats for the given year. - - The return value is a dictionary of {month: time, ...}. - """ - data = await self.get_raw_monthstat(year=year) - data = self._convert_stat_data(data["month_list"], entry_key="month") - return data - - async def erase_stats(self): - """Erase all stats.""" - return await self.call("erase_runtime_stat") - - def _convert_stat_data(self, data, entry_key) -> Dict: - """Return usage information keyed with the day/month. - - The incoming data is a list of dictionaries:: - - [{'year': int, - 'month': int, - 'day': int, <-- for get_daystat not get_monthstat - 'time': int, <-- for usage (mins) - }, ...] - - :return: return a dictionary keyed by day or month with time as the value. - """ - if not data: - return {} - - data = {entry[entry_key]: entry["time"] for entry in data} - - return data diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 940896d2e..548bebae3 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -11,8 +11,8 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 -import kasa.iot as Iot -import kasa.smart as Smart +import kasa.iot as iot +import kasa.smart as smart from kasa import ( Credentials, Device, @@ -327,34 +327,34 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return Smart.Plug + return smart.Plug for d in BULBS_SMART: if d in model: - return Smart.Bulb + return smart.Bulb for d in DIMMERS_SMART: if d in model: - return Smart.Bulb + return smart.Bulb else: for d in STRIPS_IOT: if d in model: - return Iot.Strip + return iot.Strip for d in PLUGS_IOT: if d in model: - return Iot.Plug + return iot.Plug # 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 Iot.LightStrip + return iot.LightStrip for d in BULBS_IOT: if d in model: - return Iot.Bulb + return iot.Bulb for d in DIMMERS_IOT: if d in model: - return Iot.Dimmer + return iot.Dimmer raise Exception("Unable to find type for %s", model) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7007a58fb..8a0e07dd6 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -12,8 +12,8 @@ EmeterStatus, SmartDeviceException, UnsupportedDeviceException, + iot, ) -from kasa import iot as Iot from kasa.cli import ( TYPE_TO_CLASS, alias, @@ -219,7 +219,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: Iot.Device, mocker): +async def test_emeter(dev: iot.Device, mocker): runner = CliRunner() res = await runner.invoke(emeter, obj=dev) @@ -282,7 +282,7 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: Iot.Device, mocker): +async def test_json_output(dev: iot.Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) runner = CliRunner() @@ -298,7 +298,7 @@ async def test_credentials(discovery_mock, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: Iot.Device): + async def _state(dev: iot.Device): if dev.credentials: click.echo( f"Username:{dev.credentials.username} Password:{dev.credentials.password}" @@ -519,7 +519,7 @@ async def test_type_param(device_type, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: Iot.Device): + async def _state(dev: iot.Device): nonlocal result_device result_device = dev diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index be4c8700c..e3d6c7a06 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -15,11 +15,9 @@ DeviceType, Discover, SmartDeviceException, + iot, protocol, ) -from kasa import ( - iot as Iot, -) from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -298,7 +296,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery async def test_device_update_from_new_discovery_info(discovery_data): - device = Iot.Device("127.0.0.7") + device = iot.Device("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 1bf6f29fe..ea8b92c2c 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -8,9 +8,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 import kasa -from kasa import Credentials, Device, DeviceConfig, SmartDeviceException -from kasa import iot as Iot -from kasa import smart as Smart +from kasa import Credentials, Device, DeviceConfig, SmartDeviceException, iot, smart from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -240,7 +238,7 @@ async def test_device_class_ctors(device_class_name_obj): @device_iot -async def test_modules_preserved(dev: Iot.Device): +async def test_modules_preserved(dev: iot.Device): """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() @@ -250,9 +248,9 @@ async def test_modules_preserved(dev: Iot.Device): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" - dev = Iot.Device(host, config=DeviceConfig(host, timeout=100)) + dev = iot.Device(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 - dev = Smart.Device(host, config=DeviceConfig(host, timeout=100)) + dev = smart.Device(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -276,7 +274,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: Iot.Device): +async def test_modules_not_supported(dev: iot.Device): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None From 435bb39a8c152b4a6f58a424b927c694520bba1d Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 30 Jan 2024 08:00:10 +0000 Subject: [PATCH 05/15] Update post review --- kasa/__init__.py | 16 ++++++++++++---- kasa/device_type.py | 2 -- kasa/discover.py | 8 ++++++-- kasa/iot/__init__.py | 4 ++-- kasa/iot/bulb.py | 6 +++--- kasa/iot/dimmer.py | 11 ++++++----- kasa/iot/lightstrip.py | 3 ++- kasa/iot/modules/module.py | 10 +++------- kasa/iot/plug.py | 9 +++++---- kasa/iot/strip.py | 19 +++++++++---------- kasa/tests/test_device_type.py | 2 +- 11 files changed, 49 insertions(+), 41 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 48c39c4b7..c20bd78ba 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,6 +16,7 @@ from kasa.credentials import Credentials from kasa.device import Device +from kasa.device_type import DeviceType from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -31,7 +32,6 @@ UnsupportedDeviceException, ) from kasa.iot.bulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors -from kasa.iot.device import DeviceType from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -74,7 +74,7 @@ "SmartLightStrip": iot.LightStrip, "SmartStrip": iot.Strip, "SmartDimmer": iot.Dimmer, - "SmartBulbPreset": iot.bulb.BulbPreset, + "SmartBulbPreset": iot.BulbPreset, } @@ -83,6 +83,14 @@ def __getattr__(name): warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) return globals()[f"_deprecated_{name}"] if name in deprecated_smart_devices: - warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) - return deprecated_smart_devices[name] + new_class = deprecated_smart_devices[name] + new_name = ( + ".".join(new_class.__module__.split(".")[:-1]) + "." + new_class.__name__ + ) + warn( + f"{name} is deprecated, use {new_name} instead", + DeprecationWarning, + stacklevel=1, + ) + return new_class raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/kasa/device_type.py b/kasa/device_type.py index 8373d730c..162fc4f27 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -14,8 +14,6 @@ class DeviceType(Enum): StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" - TapoPlug = "tapoplug" - TapoBulb = "tapobulb" Unknown = "unknown" @staticmethod diff --git a/kasa/discover.py b/kasa/discover.py index b22f9d283..f0642addb 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,8 +22,12 @@ get_protocol, ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType -from kasa.exceptions import TimeoutException, UnsupportedDeviceException -from kasa.iot.device import Device, SmartDeviceException +from kasa.exceptions import ( + SmartDeviceException, + TimeoutException, + UnsupportedDeviceException, +) +from kasa.iot.device import Device from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.xortransport import XorEncryption diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index a54306b52..750b3b594 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,9 +1,9 @@ """Package for supporting legacy kasa devices.""" -from .bulb import Bulb +from .bulb import Bulb, BulbPreset from .device import Device from .dimmer import Dimmer from .lightstrip import LightStrip from .plug import Plug from .strip import Strip -__all__ = ["Device", "Plug", "Bulb", "Strip", "Dimmer", "LightStrip"] +__all__ = ["Device", "Plug", "Bulb", "Strip", "Dimmer", "LightStrip", "BulbPreset"] diff --git a/kasa/iot/bulb.py b/kasa/iot/bulb.py index a28dc35b2..8e2ae6b0a 100644 --- a/kasa/iot/bulb.py +++ b/kasa/iot/bulb.py @@ -9,11 +9,11 @@ except ImportError: from pydantic import BaseModel, Field, root_validator -from kasa.iot.modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage - +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import Device, DeviceType, SmartDeviceException, requires_update +from .device import Device, SmartDeviceException, requires_update +from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage class ColorTempRange(NamedTuple): diff --git a/kasa/iot/dimmer.py b/kasa/iot/dimmer.py index f91d84e17..aadc5d7b8 100644 --- a/kasa/iot/dimmer.py +++ b/kasa/iot/dimmer.py @@ -2,11 +2,12 @@ from enum import Enum from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.iot.device import DeviceType, SmartDeviceException, requires_update -from kasa.iot.modules import AmbientLight, Motion -from kasa.iot.plug import Plug -from kasa.protocol import BaseProtocol +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .device import SmartDeviceException, requires_update +from .modules import AmbientLight, Motion +from .plug import Plug class ButtonAction(Enum): diff --git a/kasa/iot/lightstrip.py b/kasa/iot/lightstrip.py index 994aabc7a..6ee1f007d 100644 --- a/kasa/iot/lightstrip.py +++ b/kasa/iot/lightstrip.py @@ -1,11 +1,12 @@ """Module for light strips (KL430).""" from typing import Any, Dict, List, Optional +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..protocol import BaseProtocol from .bulb import Bulb -from .device import DeviceType, SmartDeviceException, requires_update +from .device import SmartDeviceException, requires_update class LightStrip(Bulb): diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 1088019a1..b96430056 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -2,13 +2,12 @@ import collections import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from ...exceptions import SmartDeviceException from ...module import Module as BaseModule if TYPE_CHECKING: - from kasa import Device from kasa.iot import Device as IotDevice @@ -33,11 +32,8 @@ class Module(BaseModule, ABC): executed during the regular update cycle. """ - def __init__(self, device: "Device", module: str): - if TYPE_CHECKING: - self._device: IotDevice = cast(IotDevice, self._device) - else: - self._device = device + def __init__(self, device: "IotDevice", module: str): + self._device = device self._module = module @abstractmethod diff --git a/kasa/iot/plug.py b/kasa/iot/plug.py index 6eab21572..2762e746d 100644 --- a/kasa/iot/plug.py +++ b/kasa/iot/plug.py @@ -2,10 +2,11 @@ import logging from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.iot.device import Device, DeviceType, requires_update -from kasa.iot.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import BaseProtocol +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .device import Device, requires_update +from .modules import Antitheft, Cloud, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/strip.py b/kasa/iot/strip.py index 7eb8966e9..2ade58856 100755 --- a/kasa/iot/strip.py +++ b/kasa/iot/strip.py @@ -4,19 +4,18 @@ from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional -from kasa.iot.device import ( +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import SmartDeviceException +from ..protocol import BaseProtocol +from .device import ( Device, - DeviceType, EmeterStatus, - SmartDeviceException, merge, requires_update, ) -from kasa.iot.modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from kasa.iot.plug import Plug - -from ..deviceconfig import DeviceConfig -from ..protocol import BaseProtocol +from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage +from .plug import Plug _LOGGER = logging.getLogger(__name__) @@ -118,7 +117,7 @@ async def update(self, update_children: bool = True): _LOGGER.debug("Initializing %s child sockets", len(children)) for child in children: self.children.append( - SmartStripPlug(self.host, parent=self, child_id=child["id"]) + StripPlug(self.host, parent=self, child_id=child["id"]) ) if update_children and self.has_emeter: @@ -244,7 +243,7 @@ def emeter_realtime(self) -> EmeterStatus: return EmeterStatus(emeter) -class SmartStripPlug(Plug): +class StripPlug(Plug): """Representation of a single socket in a power strip. This allows you to use the sockets as they were SmartPlug objects. diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py index 35d395e37..099f08626 100644 --- a/kasa/tests/test_device_type.py +++ b/kasa/tests/test_device_type.py @@ -1,4 +1,4 @@ -from kasa.iot.device import DeviceType +from kasa.device_type import DeviceType async def test_device_type_from_value(): From 7517d0a7a413d4eb0cdef3b4d9cba63f85ee93e6 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 2 Feb 2024 15:42:45 +0000 Subject: [PATCH 06/15] Add device base class attributes and rename subclasses --- devtools/create_module_fixtures.py | 8 +- devtools/dump_devinfo.py | 8 +- kasa/__init__.py | 27 ++- kasa/bulb.py | 144 +++++++++++++ kasa/cli.py | 75 ++++--- kasa/device.py | 312 ++++++++++++++++++++++++++++- kasa/device_factory.py | 33 +-- kasa/discover.py | 9 +- kasa/iot/__init__.py | 22 +- kasa/iot/bulb.py | 45 +---- kasa/iot/device.py | 181 ++--------------- kasa/iot/dimmer.py | 6 +- kasa/iot/lightstrip.py | 6 +- kasa/iot/modules/module.py | 5 +- kasa/iot/plug.py | 6 +- kasa/iot/strip.py | 20 +- kasa/module.py | 8 - kasa/plug.py | 11 + kasa/smart/__init__.py | 10 +- kasa/smart/bulb.py | 19 +- kasa/smart/childdevice.py | 6 +- kasa/smart/device.py | 45 ++++- kasa/smart/modules/emeter.py | 6 - kasa/smart/modules/module.py | 6 - kasa/smart/plug.py | 18 +- kasa/tests/conftest.py | 18 +- kasa/tests/test_bulb.py | 46 ++--- kasa/tests/test_cli.py | 29 ++- kasa/tests/test_dimmer.py | 12 +- kasa/tests/test_discovery.py | 2 +- kasa/tests/test_emeter.py | 16 +- kasa/tests/test_lightstrip.py | 18 +- kasa/tests/test_readme_examples.py | 24 +-- kasa/tests/test_smartdevice.py | 12 +- kasa/tests/test_strip.py | 6 +- 35 files changed, 784 insertions(+), 435 deletions(-) create mode 100644 kasa/bulb.py delete mode 100644 kasa/module.py create mode 100644 kasa/plug.py delete mode 100644 kasa/smart/modules/emeter.py delete mode 100644 kasa/smart/modules/module.py diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index ce9c1aa8d..97bd494ac 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -15,7 +15,7 @@ app = typer.Typer() -def create_fixtures(dev: iot.Device, outputdir: Path): +def create_fixtures(dev: iot.IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): module_dir = outputdir / name @@ -44,14 +44,16 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: iot.Device = cast(iot.Device, asyncio.run(Discover.discover_single(host))) + dev: iot.IotDevice = cast( + iot.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(iot.Device, dev) + dev = cast(iot.IotDevice, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 5a6788ae3..4c2aed56a 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -113,9 +113,9 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: iot.Device, batch_size: int): +async def handle_device(basedir, autosave, device: iot.IotDevice, batch_size: int): """Create a fixture for a single device instance.""" - if isinstance(device, smart.Device): + if isinstance(device, smart.SmartDevice): filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -268,7 +268,7 @@ def _echo_error(msg: str): async def _make_requests_or_exit( - device: smart.Device, + device: smart.SmartDevice, requests: List[SmartRequest], name: str, batch_size: int, @@ -313,7 +313,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: smart.Device, batch_size: int): +async def get_smart_fixture(device: smart.SmartDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/__init__.py b/kasa/__init__.py index c20bd78ba..c59671cb7 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,8 +12,10 @@ 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 @@ -36,6 +38,7 @@ IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) +from kasa.plug import Plug from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol @@ -53,6 +56,8 @@ "DeviceType", "EmeterStatus", "Device", + "Bulb", + "Plug", "SmartDeviceException", "AuthenticationException", "UnsupportedDeviceException", @@ -68,12 +73,12 @@ deprecated_names = ["TPLinkSmartHomeProtocol"] deprecated_smart_devices = { - "SmartDevice": iot.Device, - "SmartPlug": iot.Plug, - "SmartBulb": iot.Bulb, - "SmartLightStrip": iot.LightStrip, - "SmartStrip": iot.Strip, - "SmartDimmer": iot.Dimmer, + "SmartDevice": iot.IotDevice, + "SmartPlug": iot.IotPlug, + "SmartBulb": iot.IotBulb, + "SmartLightStrip": iot.IotLightStrip, + "SmartStrip": iot.IotStrip, + "SmartDimmer": iot.IotDimmer, "SmartBulbPreset": iot.BulbPreset, } @@ -94,3 +99,13 @@ def __getattr__(name): ) 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 diff --git a/kasa/bulb.py b/kasa/bulb.py new file mode 100644 index 000000000..5db6e5b75 --- /dev/null +++ b/kasa/bulb.py @@ -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.""" diff --git a/kasa/cli.py b/kasa/cli.py index 5cf9b3957..0264145ae 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,6 +13,7 @@ from kasa import ( AuthenticationException, + Bulb, ConnectionType, Credentials, Device, @@ -22,6 +23,7 @@ EncryptType, UnsupportedDeviceException, iot, + smart, ) from kasa.discover import DiscoveryResult @@ -58,11 +60,18 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": iot.Plug, - "bulb": iot.Bulb, - "dimmer": iot.Dimmer, - "strip": iot.Strip, - "lightstrip": iot.LightStrip, + "plug": iot.IotPlug, + "bulb": iot.IotBulb, + "dimmer": iot.IotDimmer, + "strip": iot.IotStrip, + "lightstrip": iot.IotLightStrip, + "iot.plug": iot.IotPlug, + "iot.bulb": iot.IotBulb, + "iot.dimmer": iot.IotDimmer, + "iot.strip": iot.IotStrip, + "iot.lightstrip": iot.IotLightStrip, + "smart.plug": smart.SmartPlug, + "smart.bulb": smart.SmartBulb, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -76,7 +85,7 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(iot.Device) +pass_dev = click.make_pass_decorator(Device) class ExceptionHandlerGroup(click.Group): @@ -107,7 +116,7 @@ def to_serializable(val): return str(val) @to_serializable.register(Device) - def _device_to_serializable(val: iot.Device): + def _device_to_serializable(val: Device): """Serialize smart device data, just using the last update raw payload.""" return val.internal_state @@ -379,7 +388,7 @@ async def scan(dev): @click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) @pass_dev -async def join(dev: iot.Device, 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) @@ -423,7 +432,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - async def print_discovered(dev: iot.Device): + async def print_discovered(dev: Device): async with sem: try: await dev.update() @@ -517,7 +526,7 @@ async def sysinfo(dev): @cli.command() @pass_dev @click.pass_context -async def state(ctx, dev: iot.Device): +async def state(ctx, dev: Device): """Print out device state and versions.""" verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False @@ -580,7 +589,6 @@ async def alias(dev, new_alias, index): if not dev.is_strip: echo("Index can only used for power strips!") return - dev = cast(iot.Strip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -602,7 +610,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: iot.Device, 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) @@ -613,7 +621,7 @@ async def raw_command(ctx, dev: iot.Device, 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: iot.Device, module, command, parameters): +async def cmd_command(dev: iot.IotDevice, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) @@ -630,7 +638,7 @@ async def cmd_command(dev: iot.Device, 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: iot.Device, 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. @@ -640,7 +648,6 @@ async def emeter(dev: iot.Device, index: int, name: str, year, month, erase): echo("Index and name are only for power strips!") return - dev = cast(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -651,6 +658,12 @@ async def emeter(dev: iot.Device, index: int, name: str, year, month, erase): echo("Device has no emeter") return + if (year or month or erase) and not dev.has_emeter_history: + echo("Device has no historical statistics") + return + else: + dev = cast(iot.IotDevice, dev) + if erase: echo("Erasing emeter statistics..") return await dev.erase_emeter_stats() @@ -692,7 +705,7 @@ async def emeter(dev: iot.Device, 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: iot.Device, year, month, erase): +async def usage(dev: Device, year, month, erase): """Query usage for historical consumption. Daily and monthly data provided in CSV format. @@ -730,7 +743,7 @@ async def usage(dev: iot.Device, 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: iot.Bulb, 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.") @@ -750,7 +763,7 @@ async def brightness(dev: iot.Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: iot.Bulb, 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") @@ -843,14 +856,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: iot.Device, 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(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -865,14 +877,13 @@ async def on(dev: iot.Device, 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: iot.Device, 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(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -887,14 +898,13 @@ async def off(dev: iot.Device, 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: iot.Device, 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(iot.Strip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -961,10 +971,10 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: iot.Bulb): +def presets_list(dev: iot.IotBulb): """List presets.""" - if not dev.is_bulb: - echo("Presets only supported on bulbs") + if not dev.is_bulb or not isinstance(dev, iot.IotBulb): + echo("Presets only supported on iot bulbs") return for preset in dev.presets: @@ -981,7 +991,7 @@ def presets_list(dev: iot.Bulb): @click.option("--temperature", type=int) @pass_dev async def presets_modify( - dev: iot.Bulb, index, brightness, hue, saturation, temperature + dev: iot.IotBulb, index, brightness, hue, saturation, temperature ): """Modify a preset.""" for preset in dev.presets: @@ -1010,8 +1020,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: iot.Bulb, type, last, preset): +async def turn_on_behavior(dev: iot.IotBulb, type, last, preset): """Modify bulb turn-on behavior.""" + if not dev.is_bulb or not isinstance(dev, iot.IotBulb): + echo("Presets only supported on iot bulbs") + return settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1047,9 +1060,9 @@ async def turn_on_behavior(dev: iot.Bulb, 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.smart import Device + from kasa.smart import SmartDevice - if not isinstance(dev, Device): + if not isinstance(dev, SmartDevice): raise NotImplementedError( "Credentials can only be updated on authenticated devices." ) diff --git a/kasa/device.py b/kasa/device.py index 14f8dedd6..6e2e65a9e 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -1,8 +1,35 @@ """Module for Device base class.""" import logging -from typing import Optional +from abc import 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__) @@ -10,6 +37,289 @@ class Device: """Placeholder for interface or base class.""" + def __init__( + self, + host: str, + *, + 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 + + self.modules: Dict[str, Any] = {} + self.children: Sequence["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 + + @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] + + 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() + + async def _raw_query(self, request: Union[str, Dict]) -> Any: + """Send a raw query to the device.""" + return await self.protocol.query(request=request) + + @abstractmethod + async def update(self, update_children: bool = True): + """Update the device.""" + + @property + @abstractmethod + def sys_info(self) -> Dict[str, Any]: + """Returns the device info.""" + + @property + @abstractmethod + def model(self) -> str: + """Returns the device model.""" + + @property + @abstractmethod + def alias(self) -> Optional[str]: + """Returns the device alias or nickname.""" + + @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 has_emeter_history(self) -> bool: + """Return if the device provides emeter stats.""" + + @property + @abstractmethod + def is_on(self) -> bool: + """Return true if the device is on.""" + + @property + @abstractmethod + def is_off(self) -> bool: + """Return true if the device is off.""" + + @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 turn_on(self, **kwargs) -> Optional[Dict]: + """Turn on the device.""" + + @abstractmethod + async def turn_off(self, **kwargs) -> Optional[Dict]: + """Turn off the device.""" + + @abstractmethod + def update_from_discover_info(self, info): + """Update state from info from the discover call.""" + + @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).""" + @staticmethod async def connect( *, diff --git a/kasa/device_factory.py b/kasa/device_factory.py index fe9f59aaf..e4464ead9 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -24,7 +24,7 @@ } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "iot.Device": +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 @@ -69,6 +69,7 @@ def _perf_log(has_params, perf_type): ) device_class: Optional[Type[Device]] + device: Optional[Device] = None if isinstance(protocol, IotProtocol) and isinstance( protocol._transport, XorTransport @@ -95,7 +96,7 @@ def _perf_log(has_params, perf_type): ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.Device]: +def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.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") @@ -106,32 +107,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.Device]: raise SmartDeviceException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return iot.Dimmer + return iot.IotDimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return iot.Strip + return iot.IotStrip - return iot.Plug + return iot.IotPlug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return iot.LightStrip + return iot.IotLightStrip - return iot.Bulb + return iot.IotBulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) -def get_device_class_from_family(device_type: str) -> Optional[Type[iot.Device]]: +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[iot.Device]] = { - "SMART.TAPOPLUG": smart.Plug, - "SMART.TAPOBULB": smart.Bulb, - "SMART.TAPOSWITCH": smart.Bulb, - "SMART.KASAPLUG": smart.Plug, - "SMART.KASASWITCH": smart.Bulb, - "IOT.SMARTPLUGSWITCH": iot.Plug, - "IOT.SMARTBULB": iot.Bulb, + supported_device_types: Dict[str, Type[Device]] = { + "SMART.TAPOPLUG": smart.SmartPlug, + "SMART.TAPOBULB": smart.SmartBulb, + "SMART.TAPOSWITCH": smart.SmartBulb, + "SMART.KASAPLUG": smart.SmartPlug, + "SMART.KASASWITCH": smart.SmartBulb, + "IOT.SMARTPLUGSWITCH": iot.IotPlug, + "IOT.SMARTBULB": iot.IotBulb, } return supported_device_types.get(device_type) diff --git a/kasa/discover.py b/kasa/discover.py index f0642addb..13a084d9e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,6 +15,7 @@ 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, @@ -27,7 +28,7 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.iot.device import Device +from kasa.iot.device import IotDevice from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.xortransport import XorEncryption @@ -125,7 +126,7 @@ def datagram_received(self, data, addr) -> None: return self.seen_hosts.add(ip) - device = None + device: Optional[Device] = None config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: @@ -401,7 +402,7 @@ def _get_device_class(info: dict) -> Type[Device]: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> Device: + def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: """Get SmartDevice from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) @@ -412,7 +413,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> Device: _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")): diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 750b3b594..4481fe505 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,9 +1,17 @@ """Package for supporting legacy kasa devices.""" -from .bulb import Bulb, BulbPreset -from .device import Device -from .dimmer import Dimmer -from .lightstrip import LightStrip -from .plug import Plug -from .strip import Strip +from .bulb import BulbPreset, IotBulb +from .device import IotDevice +from .dimmer import IotDimmer +from .lightstrip import IotLightStrip +from .plug import IotPlug +from .strip import IotStrip -__all__ = ["Device", "Plug", "Bulb", "Strip", "Dimmer", "LightStrip", "BulbPreset"] +__all__ = [ + "IotDevice", + "IotPlug", + "IotBulb", + "IotStrip", + "IotDimmer", + "IotLightStrip", + "BulbPreset", +] diff --git a/kasa/iot/bulb.py b/kasa/iot/bulb.py index 8e2ae6b0a..39d2ab4ab 100644 --- a/kasa/iot/bulb.py +++ b/kasa/iot/bulb.py @@ -2,52 +2,21 @@ 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 ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import Device, SmartDeviceException, requires_update +from .device import IotDevice, SmartDeviceException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -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 BehaviorMode(str, Enum): """Enum to present type of turn on behavior.""" @@ -117,7 +86,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class Bulb(Device): +class IotBulb(IotDevice, Bulb): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -133,7 +102,7 @@ class Bulb(Device): Examples: >>> import asyncio - >>> bulb = Bulb("127.0.0.1") + >>> bulb = IotBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) Bulb2 @@ -374,10 +343,6 @@ def hsv(self) -> HSV: 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, diff --git a/kasa/iot/device.py b/kasa/iot/device.py index 2b9b9e902..ea2523d64 100755 --- a/kasa/iot/device.py +++ b/kasa/iot/device.py @@ -15,40 +15,19 @@ 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 import Device as BaseDevice -from ..device_type import DeviceType +from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import SmartDeviceException -from ..iotprotocol import IotProtocol from ..protocol import BaseProtocol -from ..xortransport import XorTransport from .modules import Emeter, Module _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(): @@ -93,17 +72,17 @@ def _parse_features(features: str) -> Set[str]: return set(features.split(":")) -class Device(BaseDevice): +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. @@ -116,7 +95,7 @@ class Device(BaseDevice): Examples: >>> import asyncio - >>> dev = Device("127.0.0.1") + >>> dev = IotDevice("127.0.0.1") >>> asyncio.run(dev.update()) All devices provide several informational properties: @@ -201,57 +180,15 @@ def __init__( config: Optional[DeviceConfig] = None, protocol: Optional[BaseProtocol] = None, ) -> None: - """Create a new SmartDevice instance. + """Create a new IotDevice 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 + 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["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 + self.children: Sequence["IotDevice"] def add_module(self, name: str, module: Module): """Register a module.""" @@ -292,7 +229,7 @@ async def _query_helper( 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 @@ -334,6 +271,12 @@ def has_emeter(self) -> bool: """Return True if device has an energy meter.""" return "ENE" in self.features + @property + @requires_update + def has_emeter_history(self) -> bool: + """Return if the device provides emeter stats.""" + return self.has_emeter + async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") @@ -638,7 +581,7 @@ 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.") @@ -715,77 +658,11 @@ async def _join(target, payload): ) return await _join("smartlife.iot.common.softaponboarding", payload) - 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 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. @@ -794,21 +671,3 @@ def internal_state(self) -> Any: 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() diff --git a/kasa/iot/dimmer.py b/kasa/iot/dimmer.py index aadc5d7b8..b58d5dfc1 100644 --- a/kasa/iot/dimmer.py +++ b/kasa/iot/dimmer.py @@ -7,7 +7,7 @@ from ..protocol import BaseProtocol from .device import SmartDeviceException, requires_update from .modules import AmbientLight, Motion -from .plug import Plug +from .plug import IotPlug class ButtonAction(Enum): @@ -33,7 +33,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class Dimmer(Plug): +class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -51,7 +51,7 @@ class Dimmer(Plug): Examples: >>> import asyncio - >>> dimmer = Dimmer("192.168.1.105") + >>> dimmer = IotDimmer("192.168.1.105") >>> asyncio.run(dimmer.turn_on()) >>> dimmer.brightness 25 diff --git a/kasa/iot/lightstrip.py b/kasa/iot/lightstrip.py index 6ee1f007d..61c176435 100644 --- a/kasa/iot/lightstrip.py +++ b/kasa/iot/lightstrip.py @@ -5,11 +5,11 @@ from ..deviceconfig import DeviceConfig from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..protocol import BaseProtocol -from .bulb import Bulb +from .bulb import IotBulb from .device import SmartDeviceException, requires_update -class LightStrip(Bulb): +class IotLightStrip(IotBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -18,7 +18,7 @@ class LightStrip(Bulb): Examples: >>> import asyncio - >>> strip = LightStrip("127.0.0.1") + >>> strip = IotLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> print(strip.alias) KL430 pantry lightstrip diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index b96430056..9df4e17a0 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -5,10 +5,9 @@ from typing import TYPE_CHECKING from ...exceptions import SmartDeviceException -from ...module import Module as BaseModule if TYPE_CHECKING: - from kasa.iot import Device as IotDevice + from kasa.iot import IotDevice as IotDevice _LOGGER = logging.getLogger(__name__) @@ -25,7 +24,7 @@ def merge(d, u): return d -class Module(BaseModule, ABC): +class Module(ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be diff --git a/kasa/iot/plug.py b/kasa/iot/plug.py index 2762e746d..88e6d6a97 100644 --- a/kasa/iot/plug.py +++ b/kasa/iot/plug.py @@ -5,13 +5,13 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import Device, requires_update +from .device import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) -class Plug(Device): +class IotPlug(IotDevice): r"""Representation of a TP-Link Smart Switch. To initialize, you have to await :func:`update()` at least once. @@ -26,7 +26,7 @@ class Plug(Device): Examples: >>> import asyncio - >>> plug = Plug("127.0.0.1") + >>> plug = IotPlug("127.0.0.1") >>> asyncio.run(plug.update()) >>> plug.alias Kitchen diff --git a/kasa/iot/strip.py b/kasa/iot/strip.py index 2ade58856..12d5e3ef0 100755 --- a/kasa/iot/strip.py +++ b/kasa/iot/strip.py @@ -9,13 +9,13 @@ from ..exceptions import SmartDeviceException from ..protocol import BaseProtocol from .device import ( - Device, EmeterStatus, + IotDevice, merge, requires_update, ) from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .plug import Plug +from .plug import IotPlug _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ def merge_sums(dicts): return total_dict -class Strip(Device): +class IotStrip(IotDevice): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. @@ -48,7 +48,7 @@ class Strip(Device): Examples: >>> import asyncio - >>> strip = Strip("127.0.0.1") + >>> strip = IotStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> strip.alias TP-LINK_Power Strip_CF69 @@ -115,10 +115,10 @@ async def update(self, update_children: bool = True): if not self.children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) - for child in children: - self.children.append( - StripPlug(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: @@ -243,7 +243,7 @@ def emeter_realtime(self) -> EmeterStatus: return EmeterStatus(emeter) -class StripPlug(Plug): +class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. This allows you to use the sockets as they were SmartPlug objects. @@ -253,7 +253,7 @@ class StripPlug(Plug): The plug inherits (most of) the system information from the parent. """ - def __init__(self, host: str, parent: "Strip", child_id: str) -> None: + def __init__(self, host: str, parent: "IotStrip", child_id: str) -> None: super().__init__(host) self.parent = parent diff --git a/kasa/module.py b/kasa/module.py deleted file mode 100644 index 5d8366227..000000000 --- a/kasa/module.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Module for Module base class.""" -import logging - -_LOGGER = logging.getLogger(__name__) - - -class Module: - """Placeholder for interface or base class.""" diff --git a/kasa/plug.py b/kasa/plug.py new file mode 100644 index 000000000..1271515e5 --- /dev/null +++ b/kasa/plug.py @@ -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.""" diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py index a3b72a19c..791a0d89c 100644 --- a/kasa/smart/__init__.py +++ b/kasa/smart/__init__.py @@ -1,7 +1,7 @@ """Package for supporting tapo-branded and newer kasa devices.""" -from .bulb import Bulb -from .childdevice import ChildDevice -from .device import Device -from .plug import Plug +from .bulb import SmartBulb +from .childdevice import SmartChildDevice +from .device import SmartDevice +from .plug import SmartPlug -__all__ = ["Device", "Plug", "Bulb", "ChildDevice"] +__all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"] diff --git a/kasa/smart/bulb.py b/kasa/smart/bulb.py index a83d4417b..d12f2a275 100644 --- a/kasa/smart/bulb.py +++ b/kasa/smart/bulb.py @@ -1,10 +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 ..iot.bulb import HSV, BulbPreset, ColorTempRange -from ..iot.bulb import Bulb as IotBulb -from .device import Device +from ..smartprotocol import SmartProtocol +from .device import SmartDevice AVAILABLE_EFFECTS = { "L1": "Party", @@ -12,12 +15,22 @@ } -class Bulb(Device, IotBulb): +class SmartBulb(SmartDevice, Bulb): """Representation of a TP-Link Tapo Bulb. 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.""" diff --git a/kasa/smart/childdevice.py b/kasa/smart/childdevice.py index 0449d0fd5..d8b9f691c 100644 --- a/kasa/smart/childdevice.py +++ b/kasa/smart/childdevice.py @@ -4,10 +4,10 @@ from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .device import Device +from .device import SmartDevice -class ChildDevice(Device): +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(Device): def __init__( self, - parent: Device, + parent: SmartDevice, child_id: str, config: Optional[DeviceConfig] = None, protocol: Optional[SmartProtocol] = None, diff --git a/kasa/smart/device.py b/kasa/smart/device.py index 666ee9eb8..7ecaac544 100644 --- a/kasa/smart/device.py +++ b/kasa/smart/device.py @@ -2,23 +2,21 @@ import base64 import logging from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Set, cast +from typing import 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 ..iot.device import Device as IotDevice -from ..iot.device import WifiNetwork from ..smartprotocol import SmartProtocol -from .modules.emeter import Emeter _LOGGER = logging.getLogger(__name__) -class Device(IotDevice): - """Base class to represent a TAPO device.""" +class SmartDevice(Device): + """Base class to represent a SMART protocol based device.""" def __init__( self, @@ -32,6 +30,7 @@ def __init__( ) super().__init__(host=host, config=config, protocol=_protocol) self.protocol: SmartProtocol + self.children: Sequence["SmartDevice"] self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] self._state_information: Dict[str, Any] = {} @@ -40,10 +39,11 @@ async def _initialize_children(self): 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 .childdevice import SmartChildDevice self.children = [ - ChildDevice(parent=self, child_id=child["device_id"]) for child in children + SmartChildDevice(parent=self, child_id=child["device_id"]) + for child in children ] self._device_type = DeviceType.Strip @@ -111,7 +111,6 @@ async def _initialize_modules(self): """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]: @@ -227,6 +226,11 @@ def is_on(self) -> bool: """Return true if the device is on.""" return bool(self._info.get("device_on")) + @property + def is_off(self) -> bool: + """Return true if the device is on.""" + return not self.is_on + async def turn_on(self, **kwargs): """Turn on the device.""" await self.protocol.query({"set_device_info": {"device_on": True}}) @@ -251,6 +255,13 @@ def _convert_energy_data(self, data, scale) -> Optional[float]: """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.""" @@ -273,6 +284,22 @@ def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + @property + def has_emeter_history(self) -> bool: + """Return if the device provides emeter stats.""" + return False + + @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.""" diff --git a/kasa/smart/modules/emeter.py b/kasa/smart/modules/emeter.py deleted file mode 100644 index deb142e2c..000000000 --- a/kasa/smart/modules/emeter.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Implementation of the emeter module.""" -from ...iot.modules.emeter import Emeter as IotEmeter - - -class Emeter(IotEmeter): - """Placeholder for Smart Emeter module.""" diff --git a/kasa/smart/modules/module.py b/kasa/smart/modules/module.py deleted file mode 100644 index aff975366..000000000 --- a/kasa/smart/modules/module.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Base class for all module implementations.""" -from ...iot.modules.module import Module as IotModule - - -class Module(IotModule): # pragma: no cover - """Placeholder for Smart base module.""" diff --git a/kasa/smart/plug.py b/kasa/smart/plug.py index aac3ba41e..d4a101f0c 100644 --- a/kasa/smart/plug.py +++ b/kasa/smart/plug.py @@ -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 ..iot.device import DeviceType +from ..plug import Plug from ..smartprotocol import SmartProtocol -from .device import Device +from .device import SmartDevice _LOGGER = logging.getLogger(__name__) -class Plug(Device): +class SmartPlug(SmartDevice, Plug): """Class to represent a TAPO Plug.""" def __init__( @@ -35,11 +35,3 @@ def state_information(self) -> Dict[str, Any]: "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) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 95d3132a8..159535aee 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -346,37 +346,37 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return smart.Plug + return smart.SmartPlug for d in BULBS_SMART: if d in model: - return smart.Bulb + return smart.SmartBulb for d in DIMMERS_SMART: if d in model: - return smart.Bulb + return smart.SmartBulb for d in STRIPS_SMART: if d in model: - return smart.Plug + return smart.SmartPlug else: for d in STRIPS_IOT: if d in model: - return iot.Strip + return iot.IotStrip for d in PLUGS_IOT: if d in model: - return iot.Plug + return iot.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 iot.LightStrip + return iot.IotLightStrip for d in BULBS_IOT: if d in model: - return iot.Bulb + return iot.IotBulb for d in DIMMERS_IOT: if d in model: - return iot.Dimmer + return iot.IotDimmer raise Exception("Unable to find type for %s", model) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 2d0744ba3..5cfb9e5e9 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,8 +7,8 @@ Schema, ) -from kasa import BulbPreset, DeviceType, SmartDeviceException -from kasa.iot import Bulb +from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException +from kasa.iot import IotBulb from .conftest import ( bulb, @@ -50,7 +50,7 @@ async def test_state_attributes(dev: Bulb): @bulb_iot -async def test_light_state_without_update(dev: Bulb, 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 @@ -59,7 +59,7 @@ async def test_light_state_without_update(dev: Bulb, monkeypatch): @bulb_iot -async def test_get_light_state(dev: Bulb): +async def test_get_light_state(dev: IotBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @@ -84,8 +84,8 @@ async def test_hsv(dev: Bulb, turn_on): @color_bulb_iot -async def test_set_hsv_transition(dev: Bulb, mocker): - set_light_state = mocker.patch("kasa.iot.Bulb.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( @@ -102,15 +102,15 @@ async def test_invalid_hsv(dev: Bulb, turn_on): 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 @@ -150,15 +150,15 @@ async def test_try_set_colortemp(dev: Bulb, turn_on): @variable_temp_iot -async def test_set_color_temp_transition(dev: Bulb, mocker): - set_light_state = mocker.patch("kasa.iot.Bulb.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: Bulb, 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) @@ -200,12 +200,12 @@ async def test_dimmable_brightness(dev: Bulb, 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: Bulb, mocker): - set_light_state = mocker.patch("kasa.iot.Bulb.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) @@ -216,8 +216,8 @@ async def test_turn_on_transition(dev: Bulb, mocker): @bulb_iot -async def test_dimmable_brightness_transition(dev: Bulb, mocker): - set_light_state = mocker.patch("kasa.iot.Bulb.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) @@ -246,9 +246,9 @@ async def test_non_dimmable(dev: Bulb): @bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: Bulb, mocker + dev: IotBulb, mocker ): - query_helper = mocker.patch("kasa.iot.Bulb._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] @@ -260,7 +260,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( @bulb_iot -async def test_list_presets(dev: Bulb): +async def test_list_presets(dev: IotBulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -273,7 +273,7 @@ async def test_list_presets(dev: Bulb): @bulb_iot -async def test_modify_preset(dev: Bulb, 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") @@ -316,12 +316,12 @@ async def test_modify_preset(dev: Bulb, mocker): ), ], ) -async def test_modify_preset_payloads(dev: Bulb, 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.iot.Bulb._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) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 08909f76f..1cceec335 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -11,7 +11,6 @@ EmeterStatus, SmartDeviceException, UnsupportedDeviceException, - iot, ) from kasa.cli import ( TYPE_TO_CLASS, @@ -108,9 +107,9 @@ async def test_alias(dev): async def test_raw_command(dev, mocker): runner = CliRunner() update = mocker.patch.object(dev, "update") - from kasa.smart import Device + from kasa.smart import SmartDevice - if isinstance(dev, Device): + if isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: params = ["system", "get_sysinfo"] @@ -217,7 +216,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: iot.Device, mocker): +async def test_emeter(dev: Device, mocker): runner = CliRunner() res = await runner.invoke(emeter, obj=dev) @@ -246,16 +245,24 @@ async def test_emeter(dev: iot.Device, 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 dev.has_emeter_history: + monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) + if not dev.has_emeter_history: + 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 dev.has_emeter_history: + 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 dev.has_emeter_history: + 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) @@ -280,7 +287,7 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: iot.Device, 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() @@ -296,7 +303,7 @@ async def test_credentials(discovery_mock, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: iot.Device): + async def _state(dev: Device): if dev.credentials: click.echo( f"Username:{dev.credentials.username} Password:{dev.credentials.password}" @@ -517,7 +524,7 @@ async def test_type_param(device_type, mocker): pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: iot.Device): + async def _state(dev: Device): nonlocal result_device result_device = dev diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 8f32c1cc4..fafa95441 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -1,6 +1,6 @@ import pytest -from kasa.iot import Dimmer +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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_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(Dimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index dc26c3709..20cb0184a 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -299,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 = iot.Device("127.0.0.7") + device = iot.IotDevice("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 1069e4e4c..2a9fd4f0d 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -11,6 +11,7 @@ ) from kasa import EmeterStatus, SmartDeviceException +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,13 @@ 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() + 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 +123,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() diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 485f4f311..9ded007ab 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -2,27 +2,27 @@ from kasa import DeviceType from kasa.exceptions import SmartDeviceException -from kasa.iot import LightStrip +from kasa.iot import IotLightStrip from .conftest import lightstrip @lightstrip -async def test_lightstrip_length(dev: LightStrip): +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: LightStrip): +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: LightStrip): +async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(SmartDeviceException): await dev.set_effect("Not real") @@ -34,9 +34,9 @@ async def test_effects_lightstrip_set_effect(dev: LightStrip): @lightstrip @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( - dev: LightStrip, brightness, mocker + dev: IotLightStrip, brightness, mocker ): - query_helper = mocker.patch("kasa.iot.LightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default brightness works (100 for candy cane) if brightness == 100: @@ -52,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: LightStrip, transition, mocker + dev: IotLightStrip, transition, mocker ): - query_helper = mocker.patch("kasa.iot.LightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default (500 for candy cane) transition works if transition == 500: @@ -68,6 +68,6 @@ async def test_effects_lightstrip_set_effect_transition( @lightstrip -async def test_effects_lightstrip_has_effects(dev: LightStrip): +async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 9edfb4f98..52869e058 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -8,8 +8,8 @@ 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.iot.bulb.Bulb", return_value=p) - mocker.patch("kasa.iot.bulb.Bulb.update") + mocker.patch("kasa.iot.bulb.IotBulb", return_value=p) + mocker.patch("kasa.iot.bulb.IotBulb.update") res = xdoctest.doctest_module("kasa.iot.bulb", "all") assert not res["failed"] @@ -17,8 +17,8 @@ def test_bulb_examples(mocker): 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.iot.device.Device", return_value=p) - mocker.patch("kasa.iot.device.Device.update") + mocker.patch("kasa.iot.device.IotDevice", return_value=p) + mocker.patch("kasa.iot.device.IotDevice.update") res = xdoctest.doctest_module("kasa.iot.device", "all") assert not res["failed"] @@ -26,8 +26,8 @@ def test_smartdevice_examples(mocker): 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.iot.plug.Plug", return_value=p) - mocker.patch("kasa.iot.plug.Plug.update") + mocker.patch("kasa.iot.plug.IotPlug", return_value=p) + mocker.patch("kasa.iot.plug.IotPlug.update") res = xdoctest.doctest_module("kasa.iot.plug", "all") assert not res["failed"] @@ -35,8 +35,8 @@ def test_plug_examples(mocker): 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.iot.strip.Strip", return_value=p) - mocker.patch("kasa.iot.strip.Strip.update") + mocker.patch("kasa.iot.strip.IotStrip", return_value=p) + mocker.patch("kasa.iot.strip.IotStrip.update") res = xdoctest.doctest_module("kasa.iot.strip", "all") assert not res["failed"] @@ -44,8 +44,8 @@ def test_strip_examples(mocker): 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.iot.dimmer.Dimmer", return_value=p) - mocker.patch("kasa.iot.dimmer.Dimmer.update") + mocker.patch("kasa.iot.dimmer.IotDimmer", return_value=p) + mocker.patch("kasa.iot.dimmer.IotDimmer.update") res = xdoctest.doctest_module("kasa.iot.dimmer", "all") assert not res["failed"] @@ -53,8 +53,8 @@ def test_dimmer_examples(mocker): 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.iot.lightstrip.LightStrip", return_value=p) - mocker.patch("kasa.iot.lightstrip.LightStrip.update") + mocker.patch("kasa.iot.lightstrip.IotLightStrip", return_value=p) + mocker.patch("kasa.iot.lightstrip.IotLightStrip.update") res = xdoctest.doctest_module("kasa.iot.lightstrip", "all") assert not res["failed"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 62963d5c3..af750a95f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -242,8 +242,8 @@ async def test_device_class_ctors(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, smart.ChildDevice): - parent = smart.Device(host, config=config) + if issubclass(klass, smart.SmartChildDevice): + parent = smart.SmartDevice(host, config=config) dev = klass(parent, 1) else: dev = klass(host, config=config) @@ -253,7 +253,7 @@ async def test_device_class_ctors(device_class_name_obj): @device_iot -async def test_modules_preserved(dev: iot.Device): +async def test_modules_preserved(dev: iot.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() @@ -263,9 +263,9 @@ async def test_modules_preserved(dev: iot.Device): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" - dev = iot.Device(host, config=DeviceConfig(host, timeout=100)) + dev = iot.IotDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 - dev = smart.Device(host, config=DeviceConfig(host, timeout=100)) + dev = smart.SmartDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -289,7 +289,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: iot.Device): +async def test_modules_not_supported(dev: iot.IotDevice): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 3d6b84edb..623adde6c 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -3,7 +3,7 @@ import pytest from kasa import SmartDeviceException -from kasa.iot import Strip +from kasa.iot import IotStrip from .conftest import handle_turn_on, strip, turn_on @@ -69,7 +69,7 @@ async def test_children_on_since(dev): @strip -async def test_get_plug_by_name(dev: Strip): +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] @@ -78,7 +78,7 @@ async def test_get_plug_by_name(dev: Strip): @strip -async def test_get_plug_by_index(dev: Strip): +async def test_get_plug_by_index(dev: IotStrip): assert dev.get_plug_by_index(0) == dev.children[0] with pytest.raises(SmartDeviceException): From b455c46793b41deb687af91f6997d649ea2bf79b Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 2 Feb 2024 15:54:14 +0000 Subject: [PATCH 07/15] Rename Module to BaseModule --- kasa/iot/device.py | 4 ++-- kasa/iot/modules/__init__.py | 4 ++-- kasa/iot/modules/ambientlight.py | 4 ++-- kasa/iot/modules/cloud.py | 4 ++-- kasa/iot/modules/module.py | 2 +- kasa/iot/modules/motion.py | 4 ++-- kasa/iot/modules/rulemodule.py | 4 ++-- kasa/iot/modules/time.py | 4 ++-- kasa/iot/modules/usage.py | 4 ++-- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/kasa/iot/device.py b/kasa/iot/device.py index ea2523d64..2846fd809 100755 --- a/kasa/iot/device.py +++ b/kasa/iot/device.py @@ -23,7 +23,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import SmartDeviceException from ..protocol import BaseProtocol -from .modules import Emeter, Module +from .modules import BaseModule, Emeter _LOGGER = logging.getLogger(__name__) @@ -190,7 +190,7 @@ def __init__( self._features: Set[str] = set() self.children: Sequence["IotDevice"] - def add_module(self, name: str, module: Module): + def add_module(self, name: str, module: BaseModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 8ad5088d5..450e10710 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import Module +from .module import BaseModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +17,7 @@ "Cloud", "Countdown", "Emeter", - "Module", + "BaseModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 963c73a3f..afc42db99 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import Module +from .module import BaseModule # 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 @@ # {"name":"custom","adc":2400,"value":97}]}] -class AmbientLight(Module): +class AmbientLight(BaseModule): """Implements ambient light controls for the motion sensor.""" def query(self): diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index b4eface55..12666cd2b 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,7 +4,7 @@ except ImportError: from pydantic import BaseModel -from .module import Module +from .module import BaseModule class CloudInfo(BaseModel): @@ -22,7 +22,7 @@ class CloudInfo(BaseModel): username: str -class Cloud(Module): +class Cloud(BaseModule): """Module implementing support for cloud services.""" def query(self): diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 9df4e17a0..a2de7d062 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -24,7 +24,7 @@ def merge(d, u): return d -class Module(ABC): +class BaseModule(ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index 2c0b71d86..34c455ed8 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,7 +3,7 @@ from typing import Optional from ...exceptions import SmartDeviceException -from .module import Module +from .module import BaseModule 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(BaseModule): """Implements the motion detection (PIR) module.""" def query(self): diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 05ef500f0..1bf42787a 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -from .module import Module, merge +from .module import BaseModule, merge class Action(Enum): @@ -55,7 +55,7 @@ class Rule(BaseModel): _LOGGER = logging.getLogger(__name__) -class RuleModule(Module): +class RuleModule(BaseModule): """Base class for rule-based modules, such as countdown and antitheft.""" def query(self): diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 726d0c1b1..d4c5a2208 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,10 +2,10 @@ from datetime import datetime from ...exceptions import SmartDeviceException -from .module import Module, merge +from .module import BaseModule, merge -class Time(Module): +class Time(BaseModule): """Implements the timezone settings.""" def query(self): diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index 10b9689d3..4ce41c723 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import Dict -from .module import Module, merge +from .module import BaseModule, merge -class Usage(Module): +class Usage(BaseModule): """Baseclass for emeter/usage interfaces.""" def query(self): From 3dee032edb804fcc160bb68699aff439953c4c2f Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 2 Feb 2024 17:17:48 +0000 Subject: [PATCH 08/15] Remove has_emeter_history --- kasa/cli.py | 2 +- kasa/device.py | 5 ----- kasa/iot/device.py | 6 ------ kasa/smart/device.py | 5 ----- kasa/tests/test_cli.py | 9 +++++---- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 0264145ae..0a2a05bbb 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -658,7 +658,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): echo("Device has no emeter") return - if (year or month or erase) and not dev.has_emeter_history: + if (year or month or erase) and not isinstance(dev, iot.IotDevice): echo("Device has no historical statistics") return else: diff --git a/kasa/device.py b/kasa/device.py index 6e2e65a9e..225241eb1 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -255,11 +255,6 @@ def features(self) -> Set[str]: def has_emeter(self) -> bool: """Return if the device has emeter.""" - @property - @abstractmethod - def has_emeter_history(self) -> bool: - """Return if the device provides emeter stats.""" - @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/iot/device.py b/kasa/iot/device.py index 2846fd809..93a94f7c9 100755 --- a/kasa/iot/device.py +++ b/kasa/iot/device.py @@ -271,12 +271,6 @@ def has_emeter(self) -> bool: """Return True if device has an energy meter.""" return "ENE" in self.features - @property - @requires_update - def has_emeter_history(self) -> bool: - """Return if the device provides emeter stats.""" - return self.has_emeter - async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") diff --git a/kasa/smart/device.py b/kasa/smart/device.py index 7ecaac544..3a2eac2c7 100644 --- a/kasa/smart/device.py +++ b/kasa/smart/device.py @@ -284,11 +284,6 @@ def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) - @property - def has_emeter_history(self) -> bool: - """Return if the device provides emeter stats.""" - return False - @property def on_since(self) -> Optional[datetime]: """Return the time that the device was turned on or None if turned off.""" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 1cceec335..58370d74b 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -27,6 +27,7 @@ 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 @@ -245,22 +246,22 @@ async def test_emeter(dev: Device, mocker): assert "Voltage: 122.066 V" in res.output assert realtime_emeter.call_count == 2 - if dev.has_emeter_history: + 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 dev.has_emeter_history: + 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) - if dev.has_emeter_history: + 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 dev.has_emeter_history: + if not isinstance(dev, IotDevice): assert "Device has no historical statistics" in res.output return assert "For month" in res.output From 887ff402f948f208a336bc39b30d2d7a4a05e930 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 2 Feb 2024 19:08:25 +0000 Subject: [PATCH 09/15] Fix missing _time in init --- kasa/smart/device.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/smart/device.py b/kasa/smart/device.py index dad32f508..36a2a9a1f 100644 --- a/kasa/smart/device.py +++ b/kasa/smart/device.py @@ -38,6 +38,7 @@ def __init__( 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.""" From cf1e75daaedce7388dec660fcd1c8df170b60fb4 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sat, 3 Feb 2024 10:26:36 +0000 Subject: [PATCH 10/15] Update post review --- kasa/__init__.py | 11 +- kasa/cli.py | 54 ++-- kasa/discover.py | 2 +- kasa/iot/__init__.py | 13 +- kasa/iot/{bulb.py => iotbulb.py} | 2 +- kasa/iot/{device.py => iotdevice.py} | 4 +- kasa/iot/{dimmer.py => iotdimmer.py} | 4 +- kasa/iot/{lightstrip.py => iotlightstrip.py} | 4 +- kasa/iot/{plug.py => iotplug.py} | 2 +- kasa/iot/{strip.py => iotstrip.py} | 4 +- kasa/iot/modules/__init__.py | 4 +- kasa/iot/modules/ambientlight.py | 4 +- kasa/iot/modules/cloud.py | 4 +- kasa/iot/modules/module.py | 4 +- kasa/iot/modules/motion.py | 4 +- kasa/iot/modules/rulemodule.py | 4 +- kasa/iot/modules/time.py | 4 +- kasa/iot/modules/usage.py | 4 +- kasa/smart/__init__.py | 8 +- kasa/smart/bulb.py | 4 +- kasa/smart/iotbulb.py | 276 ++++++++++++++++++ kasa/smart/smartbulb.py | 276 ++++++++++++++++++ .../{childdevice.py => smartchilddevice.py} | 2 +- kasa/smart/{device.py => smartdevice.py} | 10 +- kasa/smart/{plug.py => smartplug.py} | 2 +- kasa/tests/test_childdevice.py | 2 +- kasa/tests/test_smartdevice.py | 15 +- 27 files changed, 644 insertions(+), 83 deletions(-) rename kasa/iot/{bulb.py => iotbulb.py} (99%) rename kasa/iot/{device.py => iotdevice.py} (99%) rename kasa/iot/{dimmer.py => iotdimmer.py} (98%) rename kasa/iot/{lightstrip.py => iotlightstrip.py} (97%) rename kasa/iot/{plug.py => iotplug.py} (98%) rename kasa/iot/{strip.py => iotstrip.py} (99%) create mode 100644 kasa/smart/iotbulb.py create mode 100644 kasa/smart/smartbulb.py rename kasa/smart/{childdevice.py => smartchilddevice.py} (97%) rename kasa/smart/{device.py => smartdevice.py} (98%) rename kasa/smart/{plug.py => smartplug.py} (96%) diff --git a/kasa/__init__.py b/kasa/__init__.py index 04ae4fe57..9eafdeaf3 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -33,7 +33,7 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.iot.bulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors +from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -79,7 +79,7 @@ "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": iot.BulbPreset, + "SmartBulbPreset": BulbPreset, } @@ -89,11 +89,10 @@ def __getattr__(name): return globals()[f"_deprecated_{name}"] if name in deprecated_smart_devices: new_class = deprecated_smart_devices[name] - new_name = ( - ".".join(new_class.__module__.split(".")[:-1]) + "." + new_class.__name__ - ) + package_name = ".".join(new_class.__module__.split(".")[:-1]) warn( - f"{name} is deprecated, use {new_name} instead", + f"{name} is deprecated, use {new_class.__name__} " + + f"from package {package_name} instead", DeprecationWarning, stacklevel=1, ) diff --git a/kasa/cli.py b/kasa/cli.py index 0a2a05bbb..b23d06543 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -21,11 +21,12 @@ DeviceFamilyType, Discover, EncryptType, + SmartDeviceException, UnsupportedDeviceException, - iot, - smart, ) 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 @@ -60,18 +61,18 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": iot.IotPlug, - "bulb": iot.IotBulb, - "dimmer": iot.IotDimmer, - "strip": iot.IotStrip, - "lightstrip": iot.IotLightStrip, - "iot.plug": iot.IotPlug, - "iot.bulb": iot.IotBulb, - "iot.dimmer": iot.IotDimmer, - "iot.strip": iot.IotStrip, - "iot.lightstrip": iot.IotLightStrip, - "smart.plug": smart.SmartPlug, - "smart.bulb": smart.SmartBulb, + "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] @@ -621,12 +622,17 @@ async def raw_command(ctx, dev: Device, 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: iot.IotDevice, 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 @@ -658,11 +664,11 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): echo("Device has no emeter") return - if (year or month or erase) and not isinstance(dev, iot.IotDevice): + if (year or month or erase) and not isinstance(dev, IotDevice): echo("Device has no historical statistics") return else: - dev = cast(iot.IotDevice, dev) + dev = cast(IotDevice, dev) if erase: echo("Erasing emeter statistics..") @@ -971,9 +977,9 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: iot.IotBulb): +def presets_list(dev: IotBulb): """List presets.""" - if not dev.is_bulb or not isinstance(dev, iot.IotBulb): + if not dev.is_bulb or not isinstance(dev, IotBulb): echo("Presets only supported on iot bulbs") return @@ -990,9 +996,7 @@ def presets_list(dev: iot.IotBulb): @click.option("--saturation", type=int) @click.option("--temperature", type=int) @pass_dev -async def presets_modify( - dev: iot.IotBulb, 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: @@ -1020,9 +1024,9 @@ 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: iot.IotBulb, 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, iot.IotBulb): + 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() diff --git a/kasa/discover.py b/kasa/discover.py index 13a084d9e..858109e2b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -28,7 +28,7 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.iot.device import IotDevice +from kasa.iot.iotdevice import IotDevice from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.xortransport import XorEncryption diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 4481fe505..2ee03d694 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,10 +1,10 @@ """Package for supporting legacy kasa devices.""" -from .bulb import BulbPreset, IotBulb -from .device import IotDevice -from .dimmer import IotDimmer -from .lightstrip import IotLightStrip -from .plug import IotPlug -from .strip import IotStrip +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", @@ -13,5 +13,4 @@ "IotStrip", "IotDimmer", "IotLightStrip", - "BulbPreset", ] diff --git a/kasa/iot/bulb.py b/kasa/iot/iotbulb.py similarity index 99% rename from kasa/iot/bulb.py rename to kasa/iot/iotbulb.py index 39d2ab4ab..7712f3d7e 100644 --- a/kasa/iot/bulb.py +++ b/kasa/iot/iotbulb.py @@ -13,7 +13,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import IotDevice, SmartDeviceException, requires_update +from .iotdevice import IotDevice, SmartDeviceException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage diff --git a/kasa/iot/device.py b/kasa/iot/iotdevice.py similarity index 99% rename from kasa/iot/device.py rename to kasa/iot/iotdevice.py index 2c75d4a99..7d8d0b590 100755 --- a/kasa/iot/device.py +++ b/kasa/iot/iotdevice.py @@ -23,7 +23,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import SmartDeviceException from ..protocol import BaseProtocol -from .modules import BaseModule, Emeter +from .modules import Emeter, IotModule _LOGGER = logging.getLogger(__name__) @@ -200,7 +200,7 @@ def children(self, children): """Initialize from a list of children.""" self._children = children - def add_module(self, name: str, module: BaseModule): + def add_module(self, name: str, module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) diff --git a/kasa/iot/dimmer.py b/kasa/iot/iotdimmer.py similarity index 98% rename from kasa/iot/dimmer.py rename to kasa/iot/iotdimmer.py index b58d5dfc1..b7b727eb1 100644 --- a/kasa/iot/dimmer.py +++ b/kasa/iot/iotdimmer.py @@ -5,9 +5,9 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import SmartDeviceException, requires_update +from .iotdevice import SmartDeviceException, requires_update +from .iotplug import IotPlug from .modules import AmbientLight, Motion -from .plug import IotPlug class ButtonAction(Enum): diff --git a/kasa/iot/lightstrip.py b/kasa/iot/iotlightstrip.py similarity index 97% rename from kasa/iot/lightstrip.py rename to kasa/iot/iotlightstrip.py index 61c176435..942b9f785 100644 --- a/kasa/iot/lightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -5,8 +5,8 @@ from ..deviceconfig import DeviceConfig from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..protocol import BaseProtocol -from .bulb import IotBulb -from .device import SmartDeviceException, requires_update +from .iotbulb import IotBulb +from .iotdevice import SmartDeviceException, requires_update class IotLightStrip(IotBulb): diff --git a/kasa/iot/plug.py b/kasa/iot/iotplug.py similarity index 98% rename from kasa/iot/plug.py rename to kasa/iot/iotplug.py index 88e6d6a97..72cba7c31 100644 --- a/kasa/iot/plug.py +++ b/kasa/iot/iotplug.py @@ -5,7 +5,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .device import IotDevice, requires_update +from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/strip.py b/kasa/iot/iotstrip.py similarity index 99% rename from kasa/iot/strip.py rename to kasa/iot/iotstrip.py index 12d5e3ef0..7cbb10b03 100755 --- a/kasa/iot/strip.py +++ b/kasa/iot/iotstrip.py @@ -8,14 +8,14 @@ from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException from ..protocol import BaseProtocol -from .device import ( +from .iotdevice import ( EmeterStatus, IotDevice, merge, requires_update, ) +from .iotplug import IotPlug from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .plug import IotPlug _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 450e10710..17a34b6e7 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import BaseModule +from .module import IotModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +17,7 @@ "Cloud", "Countdown", "Emeter", - "BaseModule", + "IotModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index afc42db99..0a7663671 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import BaseModule +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 @@ # {"name":"custom","adc":2400,"value":97}]}] -class AmbientLight(BaseModule): +class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" def query(self): diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 12666cd2b..28cf2d1eb 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,7 +4,7 @@ except ImportError: from pydantic import BaseModel -from .module import BaseModule +from .module import IotModule class CloudInfo(BaseModel): @@ -22,7 +22,7 @@ class CloudInfo(BaseModel): username: str -class Cloud(BaseModule): +class Cloud(IotModule): """Module implementing support for cloud services.""" def query(self): diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index a2de7d062..51d4b350d 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -7,7 +7,7 @@ from ...exceptions import SmartDeviceException if TYPE_CHECKING: - from kasa.iot import IotDevice as IotDevice + from kasa.iot import IotDevice _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def merge(d, u): return d -class BaseModule(ABC): +class IotModule(ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index 34c455ed8..cd79cba79 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,7 +3,7 @@ from typing import Optional from ...exceptions import SmartDeviceException -from .module import BaseModule +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(BaseModule): +class Motion(IotModule): """Implements the motion detection (PIR) module.""" def query(self): diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 1bf42787a..f840f6725 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -from .module import BaseModule, merge +from .module import IotModule, merge class Action(Enum): @@ -55,7 +55,7 @@ class Rule(BaseModel): _LOGGER = logging.getLogger(__name__) -class RuleModule(BaseModule): +class RuleModule(IotModule): """Base class for rule-based modules, such as countdown and antitheft.""" def query(self): diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index d4c5a2208..2099e22c4 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,10 +2,10 @@ from datetime import datetime from ...exceptions import SmartDeviceException -from .module import BaseModule, merge +from .module import IotModule, merge -class Time(BaseModule): +class Time(IotModule): """Implements the timezone settings.""" def query(self): diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index 4ce41c723..29dcd1727 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import Dict -from .module import BaseModule, merge +from .module import IotModule, merge -class Usage(BaseModule): +class Usage(IotModule): """Baseclass for emeter/usage interfaces.""" def query(self): diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py index 791a0d89c..c075ba321 100644 --- a/kasa/smart/__init__.py +++ b/kasa/smart/__init__.py @@ -1,7 +1,7 @@ """Package for supporting tapo-branded and newer kasa devices.""" -from .bulb import SmartBulb -from .childdevice import SmartChildDevice -from .device import SmartDevice -from .plug import SmartPlug +from .smartbulb import SmartBulb +from .smartchilddevice import SmartChildDevice +from .smartdevice import SmartDevice +from .smartplug import SmartPlug __all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"] diff --git a/kasa/smart/bulb.py b/kasa/smart/bulb.py index d12f2a275..3ce4c6eb4 100644 --- a/kasa/smart/bulb.py +++ b/kasa/smart/bulb.py @@ -5,9 +5,9 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException -from ..iot.bulb import HSV, BulbPreset, ColorTempRange +from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange from ..smartprotocol import SmartProtocol -from .device import SmartDevice +from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { "L1": "Party", diff --git a/kasa/smart/iotbulb.py b/kasa/smart/iotbulb.py new file mode 100644 index 000000000..3ce4c6eb4 --- /dev/null +++ b/kasa/smart/iotbulb.py @@ -0,0 +1,276 @@ +"""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 ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from ..smartprotocol import SmartProtocol +from .smartdevice import SmartDevice + +AVAILABLE_EFFECTS = { + "L1": "Party", + "L2": "Relax", +} + + +class SmartBulb(SmartDevice, Bulb): + """Representation of a TP-Link Tapo Bulb. + + 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.""" + # TODO: this makes an assumption that only color bulbs report this + return "hue" in self._info + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + # TODO: this makes an assumption that only dimmables report this + return "brightness" in self._info + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + ct = self._info.get("color_temp_range") + # L900 reports [9000, 9000] even when it doesn't support changing the ct + return ct is not None and ct[0] != ct[1] + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise SmartDeviceException("Color temperature not supported") + + ct_range = self._info.get("color_temp_range", [0, 0]) + return ColorTempRange(min=ct_range[0], max=ct_range[1]) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "dynamic_light_effect_enable" in self._info + + @property + def effect(self) -> Dict: + """Return effect state. + + This follows the format used by SmartLightStrip. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # If no effect is active, dynamic_light_effect_id does not appear in info + current_effect = self._info.get("dynamic_light_effect_id", "") + data = { + "brightness": self.brightness, + "enable": current_effect != "", + "id": current_effect, + "name": AVAILABLE_EFFECTS.get(current_effect, ""), + } + + return data + + @property + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + h, s, v = ( + self._info.get("hue", 0), + self._info.get("saturation", 0), + self._info.get("brightness", 0), + ) + + return HSV(hue=h, saturation=s, value=v) + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + return self._info.get("color_temp", -1) + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return self._info.get("brightness", -1) + + 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. + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + if not isinstance(hue, int) or not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int) or not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + + return await self.protocol.query({"set_device_info": {**request_payload}}) + + 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. + """ + # TODO: Note, trying to set brightness at the same time + # with color_temp causes error -1008 + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + + 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. + """ + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return await self.protocol.query( + {"set_device_info": {"brightness": brightness}} + ) + + # Default state information, should be made to settings + """ + "info": { + "default_states": { + "re_power_type": "always_on", + "type": "last_states", + "state": { + "brightness": 36, + "hue": 0, + "saturation": 0, + "color_temp": 2700, + }, + }, + """ + + async def set_effect( + self, + effect: str, + *, + brightness: Optional[int] = None, + transition: Optional[int] = None, + ) -> None: + """Set an effect on the device.""" + raise NotImplementedError() + # TODO: the code below does to activate the effect but gives no error + return await self.protocol.query( + { + "set_device_info": { + "dynamic_light_effect_enable": 1, + "dynamic_light_effect_id": effect, + } + } + ) + + @property # type: ignore + def state_information(self) -> Dict[str, Any]: + """Return bulb-specific state information.""" + info: Dict[str, Any] = { + # TODO: re-enable after we don't inherit from smartbulb + # **super().state_information + "Is dimmable": self.is_dimmable, + } + if self.is_dimmable: + info["Brightness"] = self.brightness + if self.is_variable_color_temp: + info["Color temperature"] = self.color_temp + info["Valid temperature range"] = self.valid_temperature_range + if self.is_color: + info["HSV"] = self.hsv + info["Presets"] = self.presets + + return info + + @property + def presets(self) -> List[BulbPreset]: + """Return a list of available bulb setting presets.""" + return [] diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py new file mode 100644 index 000000000..3ce4c6eb4 --- /dev/null +++ b/kasa/smart/smartbulb.py @@ -0,0 +1,276 @@ +"""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 ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from ..smartprotocol import SmartProtocol +from .smartdevice import SmartDevice + +AVAILABLE_EFFECTS = { + "L1": "Party", + "L2": "Relax", +} + + +class SmartBulb(SmartDevice, Bulb): + """Representation of a TP-Link Tapo Bulb. + + 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.""" + # TODO: this makes an assumption that only color bulbs report this + return "hue" in self._info + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + # TODO: this makes an assumption that only dimmables report this + return "brightness" in self._info + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + ct = self._info.get("color_temp_range") + # L900 reports [9000, 9000] even when it doesn't support changing the ct + return ct is not None and ct[0] != ct[1] + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise SmartDeviceException("Color temperature not supported") + + ct_range = self._info.get("color_temp_range", [0, 0]) + return ColorTempRange(min=ct_range[0], max=ct_range[1]) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "dynamic_light_effect_enable" in self._info + + @property + def effect(self) -> Dict: + """Return effect state. + + This follows the format used by SmartLightStrip. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # If no effect is active, dynamic_light_effect_id does not appear in info + current_effect = self._info.get("dynamic_light_effect_id", "") + data = { + "brightness": self.brightness, + "enable": current_effect != "", + "id": current_effect, + "name": AVAILABLE_EFFECTS.get(current_effect, ""), + } + + return data + + @property + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + h, s, v = ( + self._info.get("hue", 0), + self._info.get("saturation", 0), + self._info.get("brightness", 0), + ) + + return HSV(hue=h, saturation=s, value=v) + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + return self._info.get("color_temp", -1) + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return self._info.get("brightness", -1) + + 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. + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + if not isinstance(hue, int) or not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int) or not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + + return await self.protocol.query({"set_device_info": {**request_payload}}) + + 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. + """ + # TODO: Note, trying to set brightness at the same time + # with color_temp causes error -1008 + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + + 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. + """ + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return await self.protocol.query( + {"set_device_info": {"brightness": brightness}} + ) + + # Default state information, should be made to settings + """ + "info": { + "default_states": { + "re_power_type": "always_on", + "type": "last_states", + "state": { + "brightness": 36, + "hue": 0, + "saturation": 0, + "color_temp": 2700, + }, + }, + """ + + async def set_effect( + self, + effect: str, + *, + brightness: Optional[int] = None, + transition: Optional[int] = None, + ) -> None: + """Set an effect on the device.""" + raise NotImplementedError() + # TODO: the code below does to activate the effect but gives no error + return await self.protocol.query( + { + "set_device_info": { + "dynamic_light_effect_enable": 1, + "dynamic_light_effect_id": effect, + } + } + ) + + @property # type: ignore + def state_information(self) -> Dict[str, Any]: + """Return bulb-specific state information.""" + info: Dict[str, Any] = { + # TODO: re-enable after we don't inherit from smartbulb + # **super().state_information + "Is dimmable": self.is_dimmable, + } + if self.is_dimmable: + info["Brightness"] = self.brightness + if self.is_variable_color_temp: + info["Color temperature"] = self.color_temp + info["Valid temperature range"] = self.valid_temperature_range + if self.is_color: + info["HSV"] = self.hsv + info["Presets"] = self.presets + + return info + + @property + def presets(self) -> List[BulbPreset]: + """Return a list of available bulb setting presets.""" + return [] diff --git a/kasa/smart/childdevice.py b/kasa/smart/smartchilddevice.py similarity index 97% rename from kasa/smart/childdevice.py rename to kasa/smart/smartchilddevice.py index 205f3218e..69648d5e2 100644 --- a/kasa/smart/childdevice.py +++ b/kasa/smart/smartchilddevice.py @@ -4,7 +4,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .device import SmartDevice +from .smartdevice import SmartDevice class SmartChildDevice(SmartDevice): diff --git a/kasa/smart/device.py b/kasa/smart/smartdevice.py similarity index 98% rename from kasa/smart/device.py rename to kasa/smart/smartdevice.py index 36a2a9a1f..7a068625d 100644 --- a/kasa/smart/device.py +++ b/kasa/smart/smartdevice.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from .childdevice import SmartChildDevice + from .smartchilddevice import SmartChildDevice class SmartDevice(Device): @@ -45,7 +45,7 @@ async def _initialize_children(self): 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 SmartChildDevice + from .smartchilddevice import SmartChildDevice self._children = { child["device_id"]: SmartChildDevice( @@ -111,8 +111,6 @@ async def update(self, update_children: bool = True): "emeter": self._emeter, "child_info": resp.get("get_child_device_list", {}), } - if not self.children: - pass if child_info := self._last_update.get("child_info"): if not self.children: await self._initialize_children() @@ -209,9 +207,9 @@ def internal_state(self) -> Any: 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 diff --git a/kasa/smart/plug.py b/kasa/smart/smartplug.py similarity index 96% rename from kasa/smart/plug.py rename to kasa/smart/smartplug.py index d4a101f0c..bd96b4217 100644 --- a/kasa/smart/plug.py +++ b/kasa/smart/smartplug.py @@ -6,7 +6,7 @@ from ..deviceconfig import DeviceConfig from ..plug import Plug from ..smartprotocol import SmartProtocol -from .device import SmartDevice +from .smartdevice import SmartDevice _LOGGER = logging.getLogger(__name__) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index a7098c788..3247c9173 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,7 +3,7 @@ import pytest -from kasa.smart.childdevice import SmartChildDevice +from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smartprotocol import _ChildProtocolWrapper from .conftest import strip_smart diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index af750a95f..ab95ddb16 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -298,10 +298,19 @@ async def test_modules_not_supported(dev: iot.IotDevice): assert module.is_supported is not None -@pytest.mark.parametrize("device_class", kasa.deprecated_smart_devices.keys()) -def test_deprecated_devices(device_class): - with pytest.deprecated_call(): +@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): From b44c0c82bf09a39596fcd6fd2e7f1627e399d4e5 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sat, 3 Feb 2024 10:29:30 +0000 Subject: [PATCH 11/15] Fix test_readmeexamples --- kasa/tests/test_readme_examples.py | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 52869e058..ec2099c65 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -8,54 +8,54 @@ 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.iot.bulb.IotBulb", return_value=p) - mocker.patch("kasa.iot.bulb.IotBulb.update") - res = xdoctest.doctest_module("kasa.iot.bulb", "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.iot.device.IotDevice", return_value=p) - mocker.patch("kasa.iot.device.IotDevice.update") - res = xdoctest.doctest_module("kasa.iot.device", "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.iot.plug.IotPlug", return_value=p) - mocker.patch("kasa.iot.plug.IotPlug.update") - res = xdoctest.doctest_module("kasa.iot.plug", "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.iot.strip.IotStrip", return_value=p) - mocker.patch("kasa.iot.strip.IotStrip.update") - res = xdoctest.doctest_module("kasa.iot.strip", "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.iot.dimmer.IotDimmer", return_value=p) - mocker.patch("kasa.iot.dimmer.IotDimmer.update") - res = xdoctest.doctest_module("kasa.iot.dimmer", "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.iot.lightstrip.IotLightStrip", return_value=p) - mocker.patch("kasa.iot.lightstrip.IotLightStrip.update") - res = xdoctest.doctest_module("kasa.iot.lightstrip", "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"] From b14bf70d9abed04a0cccfb77760de1db914c3160 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sat, 3 Feb 2024 11:33:52 +0000 Subject: [PATCH 12/15] Fix erroneously duped files --- kasa/smart/bulb.py | 276 ------------------------------------------ kasa/smart/iotbulb.py | 276 ------------------------------------------ 2 files changed, 552 deletions(-) delete mode 100644 kasa/smart/bulb.py delete mode 100644 kasa/smart/iotbulb.py diff --git a/kasa/smart/bulb.py b/kasa/smart/bulb.py deleted file mode 100644 index 3ce4c6eb4..000000000 --- a/kasa/smart/bulb.py +++ /dev/null @@ -1,276 +0,0 @@ -"""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 ..iot.iotbulb import HSV, BulbPreset, ColorTempRange -from ..smartprotocol import SmartProtocol -from .smartdevice import SmartDevice - -AVAILABLE_EFFECTS = { - "L1": "Party", - "L2": "Relax", -} - - -class SmartBulb(SmartDevice, Bulb): - """Representation of a TP-Link Tapo Bulb. - - 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.""" - # TODO: this makes an assumption that only color bulbs report this - return "hue" in self._info - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - # TODO: this makes an assumption that only dimmables report this - return "brightness" in self._info - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - ct = self._info.get("color_temp_range") - # L900 reports [9000, 9000] even when it doesn't support changing the ct - return ct is not None and ct[0] != ct[1] - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise SmartDeviceException("Color temperature not supported") - - ct_range = self._info.get("color_temp_range", [0, 0]) - return ColorTempRange(min=ct_range[0], max=ct_range[1]) - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "dynamic_light_effect_enable" in self._info - - @property - def effect(self) -> Dict: - """Return effect state. - - This follows the format used by SmartLightStrip. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # If no effect is active, dynamic_light_effect_id does not appear in info - current_effect = self._info.get("dynamic_light_effect_id", "") - data = { - "brightness": self.brightness, - "enable": current_effect != "", - "id": current_effect, - "name": AVAILABLE_EFFECTS.get(current_effect, ""), - } - - return data - - @property - def effect_list(self) -> Optional[List[str]]: - """Return built-in effects list. - - Example: - ['Party', 'Relax', ...] - """ - return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - h, s, v = ( - self._info.get("hue", 0), - self._info.get("saturation", 0), - self._info.get("brightness", 0), - ) - - return HSV(hue=h, saturation=s, value=v) - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - return self._info.get("color_temp", -1) - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return self._info.get("brightness", -1) - - 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. - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - - if not isinstance(saturation, int) or not (0 <= saturation <= 100): - raise ValueError( - f"Invalid saturation value: {saturation} (valid range: 0-100%)" - ) - - if value is not None: - self._raise_for_invalid_brightness(value) - - request_payload = { - "color_temp": 0, # If set, color_temp takes precedence over hue&sat - "hue": hue, - "saturation": saturation, - } - # The device errors on invalid brightness values. - if value is not None: - request_payload["brightness"] = value - - return await self.protocol.query({"set_device_info": {**request_payload}}) - - 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. - """ - # TODO: Note, trying to set brightness at the same time - # with color_temp causes error -1008 - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - valid_temperature_range = self.valid_temperature_range - if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: - raise ValueError( - "Temperature should be between {} and {}, was {}".format( - *valid_temperature_range, temp - ) - ) - - return await self.protocol.query({"set_device_info": {"color_temp": temp}}) - - 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. - """ - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return await self.protocol.query( - {"set_device_info": {"brightness": brightness}} - ) - - # Default state information, should be made to settings - """ - "info": { - "default_states": { - "re_power_type": "always_on", - "type": "last_states", - "state": { - "brightness": 36, - "hue": 0, - "saturation": 0, - "color_temp": 2700, - }, - }, - """ - - async def set_effect( - self, - effect: str, - *, - brightness: Optional[int] = None, - transition: Optional[int] = None, - ) -> None: - """Set an effect on the device.""" - raise NotImplementedError() - # TODO: the code below does to activate the effect but gives no error - return await self.protocol.query( - { - "set_device_info": { - "dynamic_light_effect_enable": 1, - "dynamic_light_effect_id": effect, - } - } - ) - - @property # type: ignore - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - # TODO: re-enable after we don't inherit from smartbulb - # **super().state_information - "Is dimmable": self.is_dimmable, - } - if self.is_dimmable: - info["Brightness"] = self.brightness - if self.is_variable_color_temp: - info["Color temperature"] = self.color_temp - info["Valid temperature range"] = self.valid_temperature_range - if self.is_color: - info["HSV"] = self.hsv - info["Presets"] = self.presets - - return info - - @property - def presets(self) -> List[BulbPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/smart/iotbulb.py b/kasa/smart/iotbulb.py deleted file mode 100644 index 3ce4c6eb4..000000000 --- a/kasa/smart/iotbulb.py +++ /dev/null @@ -1,276 +0,0 @@ -"""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 ..iot.iotbulb import HSV, BulbPreset, ColorTempRange -from ..smartprotocol import SmartProtocol -from .smartdevice import SmartDevice - -AVAILABLE_EFFECTS = { - "L1": "Party", - "L2": "Relax", -} - - -class SmartBulb(SmartDevice, Bulb): - """Representation of a TP-Link Tapo Bulb. - - 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.""" - # TODO: this makes an assumption that only color bulbs report this - return "hue" in self._info - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - # TODO: this makes an assumption that only dimmables report this - return "brightness" in self._info - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - ct = self._info.get("color_temp_range") - # L900 reports [9000, 9000] even when it doesn't support changing the ct - return ct is not None and ct[0] != ct[1] - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise SmartDeviceException("Color temperature not supported") - - ct_range = self._info.get("color_temp_range", [0, 0]) - return ColorTempRange(min=ct_range[0], max=ct_range[1]) - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "dynamic_light_effect_enable" in self._info - - @property - def effect(self) -> Dict: - """Return effect state. - - This follows the format used by SmartLightStrip. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # If no effect is active, dynamic_light_effect_id does not appear in info - current_effect = self._info.get("dynamic_light_effect_id", "") - data = { - "brightness": self.brightness, - "enable": current_effect != "", - "id": current_effect, - "name": AVAILABLE_EFFECTS.get(current_effect, ""), - } - - return data - - @property - def effect_list(self) -> Optional[List[str]]: - """Return built-in effects list. - - Example: - ['Party', 'Relax', ...] - """ - return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - h, s, v = ( - self._info.get("hue", 0), - self._info.get("saturation", 0), - self._info.get("brightness", 0), - ) - - return HSV(hue=h, saturation=s, value=v) - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - return self._info.get("color_temp", -1) - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return self._info.get("brightness", -1) - - 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. - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - - if not isinstance(saturation, int) or not (0 <= saturation <= 100): - raise ValueError( - f"Invalid saturation value: {saturation} (valid range: 0-100%)" - ) - - if value is not None: - self._raise_for_invalid_brightness(value) - - request_payload = { - "color_temp": 0, # If set, color_temp takes precedence over hue&sat - "hue": hue, - "saturation": saturation, - } - # The device errors on invalid brightness values. - if value is not None: - request_payload["brightness"] = value - - return await self.protocol.query({"set_device_info": {**request_payload}}) - - 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. - """ - # TODO: Note, trying to set brightness at the same time - # with color_temp causes error -1008 - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - valid_temperature_range = self.valid_temperature_range - if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: - raise ValueError( - "Temperature should be between {} and {}, was {}".format( - *valid_temperature_range, temp - ) - ) - - return await self.protocol.query({"set_device_info": {"color_temp": temp}}) - - 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. - """ - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return await self.protocol.query( - {"set_device_info": {"brightness": brightness}} - ) - - # Default state information, should be made to settings - """ - "info": { - "default_states": { - "re_power_type": "always_on", - "type": "last_states", - "state": { - "brightness": 36, - "hue": 0, - "saturation": 0, - "color_temp": 2700, - }, - }, - """ - - async def set_effect( - self, - effect: str, - *, - brightness: Optional[int] = None, - transition: Optional[int] = None, - ) -> None: - """Set an effect on the device.""" - raise NotImplementedError() - # TODO: the code below does to activate the effect but gives no error - return await self.protocol.query( - { - "set_device_info": { - "dynamic_light_effect_enable": 1, - "dynamic_light_effect_id": effect, - } - } - ) - - @property # type: ignore - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - # TODO: re-enable after we don't inherit from smartbulb - # **super().state_information - "Is dimmable": self.is_dimmable, - } - if self.is_dimmable: - info["Brightness"] = self.brightness - if self.is_variable_color_temp: - info["Color temperature"] = self.color_temp - info["Valid temperature range"] = self.valid_temperature_range - if self.is_color: - info["HSV"] = self.hsv - info["Presets"] = self.presets - - return info - - @property - def presets(self) -> List[BulbPreset]: - """Return a list of available bulb setting presets.""" - return [] From 2845bf070a913a3a5e23911388af26f07ed3fbda Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sat, 3 Feb 2024 12:01:16 +0000 Subject: [PATCH 13/15] Clean up iot and smart imports --- devtools/create_module_fixtures.py | 11 +++++------ devtools/dump_devinfo.py | 12 ++++++------ kasa/device_factory.py | 29 +++++++++++++++-------------- kasa/tests/conftest.py | 22 +++++++++++----------- kasa/tests/test_discovery.py | 4 ++-- kasa/tests/test_smartdevice.py | 16 +++++++++------- 6 files changed, 48 insertions(+), 46 deletions(-) diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 97bd494ac..8372bfff5 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -10,12 +10,13 @@ import typer -from kasa import Discover, iot +from kasa import Discover +from kasa.iot import IotDevice app = typer.Typer() -def create_fixtures(dev: iot.IotDevice, 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 @@ -44,16 +45,14 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: iot.IotDevice = cast( - iot.IotDevice, 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(iot.IotDevice, dev) + dev = cast(IotDevice, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index cdef8aa78..c1436aa12 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -19,18 +19,18 @@ import asyncclick as click -import kasa.iot as iot -import kasa.smart as smart from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest from kasa import ( AuthenticationException, Credentials, + Device, Discover, SmartDeviceException, TimeoutException, ) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode +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: iot.IotDevice, 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, smart.SmartDevice): + 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) @@ -274,7 +274,7 @@ def _echo_error(msg: str): async def _make_requests_or_exit( - device: smart.SmartDevice, + device: SmartDevice, requests: List[SmartRequest], name: str, batch_size: int, @@ -319,7 +319,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: smart.SmartDevice, batch_size: int): +async def get_smart_fixture(device: SmartDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/device_factory.py b/kasa/device_factory.py index e4464ead9..28a5e3b2b 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -3,17 +3,18 @@ import time from typing import Any, Dict, Optional, Tuple, Type -from . import iot, smart 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 .smart import SmartBulb, SmartPlug from .smartprotocol import SmartProtocol from .xortransport import XorTransport @@ -96,7 +97,7 @@ def _perf_log(has_params, perf_type): ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.IotDevice]: +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") @@ -107,32 +108,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[iot.IotDevice]: raise SmartDeviceException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return iot.IotDimmer + return IotDimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return iot.IotStrip + return IotStrip - return iot.IotPlug + return IotPlug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return iot.IotLightStrip + return IotLightStrip - return iot.IotBulb + return IotBulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) 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[Device]] = { - "SMART.TAPOPLUG": smart.SmartPlug, - "SMART.TAPOBULB": smart.SmartBulb, - "SMART.TAPOSWITCH": smart.SmartBulb, - "SMART.KASAPLUG": smart.SmartPlug, - "SMART.KASASWITCH": smart.SmartBulb, - "IOT.SMARTPLUGSWITCH": iot.IotPlug, - "IOT.SMARTBULB": iot.IotBulb, + "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) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index c986b611e..b6e9135c8 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -11,8 +11,6 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 -import kasa.iot as iot -import kasa.smart as smart from kasa import ( Credentials, Device, @@ -20,7 +18,9 @@ Discover, SmartProtocol, ) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.protocol import BaseTransport +from kasa.smart import SmartBulb, SmartPlug from kasa.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol @@ -346,37 +346,37 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return smart.SmartPlug + return SmartPlug for d in BULBS_SMART: if d in model: - return smart.SmartBulb + return SmartBulb for d in DIMMERS_SMART: if d in model: - return smart.SmartBulb + return SmartBulb for d in STRIPS_SMART: if d in model: - return smart.SmartPlug + return SmartPlug else: for d in STRIPS_IOT: if d in model: - return iot.IotStrip + return IotStrip for d in PLUGS_IOT: if d in model: - return iot.IotPlug + 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 iot.IotLightStrip + return IotLightStrip for d in BULBS_IOT: if d in model: - return iot.IotBulb + return IotBulb for d in DIMMERS_IOT: if d in model: - return iot.IotDimmer + return IotDimmer raise Exception("Unable to find type for %s", model) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 20cb0184a..e0a7fdd41 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -14,7 +14,6 @@ DeviceType, Discover, SmartDeviceException, - iot, ) from kasa.deviceconfig import ( ConnectionType, @@ -22,6 +21,7 @@ ) 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 ( @@ -299,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 = iot.IotDevice("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" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ab95ddb16..ba5ebc4fe 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -20,7 +20,9 @@ ) import kasa -from kasa import Credentials, Device, DeviceConfig, SmartDeviceException, iot, smart +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 @@ -242,8 +244,8 @@ async def test_device_class_ctors(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, smart.SmartChildDevice): - parent = smart.SmartDevice(host, config=config) + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) dev = klass(parent, 1) else: dev = klass(host, config=config) @@ -253,7 +255,7 @@ async def test_device_class_ctors(device_class_name_obj): @device_iot -async def test_modules_preserved(dev: iot.IotDevice): +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() @@ -263,9 +265,9 @@ async def test_modules_preserved(dev: iot.IotDevice): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" - dev = iot.IotDevice(host, config=DeviceConfig(host, timeout=100)) + dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 - dev = smart.SmartDevice(host, config=DeviceConfig(host, timeout=100)) + dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -289,7 +291,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: iot.IotDevice): +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 From ccd507859ab2087e731f67fdd8d1e60587c49e78 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 4 Feb 2024 11:14:30 +0000 Subject: [PATCH 14/15] Update post latest review --- kasa/__init__.py | 3 +- kasa/cli.py | 3 - kasa/device.py | 194 +++++++++++++++++++------------------- kasa/iot/iotdevice.py | 11 +-- kasa/smart/smartdevice.py | 6 +- kasa/tests/test_emeter.py | 2 + 6 files changed, 104 insertions(+), 115 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 9eafdeaf3..0d9e0c3eb 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -92,7 +92,8 @@ def __getattr__(name): package_name = ".".join(new_class.__module__.split(".")[:-1]) warn( f"{name} is deprecated, use {new_class.__name__} " - + f"from package {package_name} instead", + + f"from package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", DeprecationWarning, stacklevel=1, ) diff --git a/kasa/cli.py b/kasa/cli.py index b23d06543..a1aa0e535 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1063,9 +1063,6 @@ async def turn_on_behavior(dev: IotBulb, 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.smart import SmartDevice - if not isinstance(dev, SmartDevice): raise NotImplementedError( "Credentials can only be updated on authenticated devices." diff --git a/kasa/device.py b/kasa/device.py index ce767bd27..d74608dd2 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -35,7 +35,7 @@ class WifiNetwork: class Device(ABC): - """Placeholder for interface or base class.""" + """Common device interface.""" def __init__( self, @@ -44,9 +44,11 @@ def __init__( config: Optional[DeviceConfig] = None, protocol: Optional[BaseProtocol] = None, ) -> None: - """Create a new SmartDevice instance. + """Create a new Device instance. - :param str host: host name or ip address on which the device listens + :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 @@ -63,6 +65,58 @@ def __init__( 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.""" @@ -97,6 +151,39 @@ 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.""" @@ -158,52 +245,6 @@ def get_plug_by_index(self, index: int) -> "Device": ) return self.children[index] - 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() - - async def _raw_query(self, request: Union[str, Dict]) -> Any: - """Send a raw query to the device.""" - return await self.protocol.query(request=request) - - @abstractmethod - async def update(self, update_children: bool = True): - """Update the device.""" - - @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 - @abstractmethod - def model(self) -> str: - """Returns the device model.""" - - @property - @abstractmethod - def alias(self) -> Optional[str]: - """Returns the device alias or nickname.""" - @property @abstractmethod def time(self) -> datetime: @@ -259,33 +300,11 @@ def features(self) -> Set[str]: def has_emeter(self) -> bool: """Return if the device has emeter.""" - @property - @abstractmethod - def is_on(self) -> bool: - """Return true if the device is on.""" - - @property - @abstractmethod - def is_off(self) -> bool: - """Return true if the device is off.""" - @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 turn_on(self, **kwargs) -> Optional[Dict]: - """Turn on the device.""" - - @abstractmethod - async def turn_off(self, **kwargs) -> Optional[Dict]: - """Turn off the device.""" - - @abstractmethod - def update_from_discover_info(self, info): - """Update state from info from the discover call.""" - @abstractmethod async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" @@ -319,28 +338,11 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): async def set_alias(self, alias: str): """Set the device name (alias).""" - @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] + 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}>" + ) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 7d8d0b590..8e51cac65 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -180,10 +180,7 @@ def __init__( config: Optional[DeviceConfig] = None, protocol: Optional[BaseProtocol] = None, ) -> None: - """Create a new IotDevice instance. - - :param str host: host name or ip address on which the device listens - """ + """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 @@ -579,12 +576,6 @@ async def turn_off(self, **kwargs) -> Dict: """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) -> Optional[Dict]: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 7a068625d..ca9ed63be 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -111,6 +111,7 @@ async def update(self, update_children: bool = True): "emeter": self._emeter, "child_info": resp.get("get_child_device_list", {}), } + if child_info := self._last_update.get("child_info"): if not self.children: await self._initialize_children() @@ -241,11 +242,6 @@ def is_on(self) -> bool: """Return true if the device is on.""" return bool(self._info.get("device_on")) - @property - def is_off(self) -> bool: - """Return true if the device is on.""" - return not self.is_on - async def turn_on(self, **kwargs): """Turn on the device.""" await self.protocol.query({"set_device_info": {"device_on": True}}) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 2a9fd4f0d..809764fad 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -40,6 +40,8 @@ async def test_no_emeter(dev): with pytest.raises(SmartDeviceException): await dev.get_emeter_realtime() + # 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() From f8d4157f83ae15fefebf0100778908e9e05b22f4 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 4 Feb 2024 11:20:58 +0000 Subject: [PATCH 15/15] Tweak Device docstring --- kasa/device.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kasa/device.py b/kasa/device.py index d74608dd2..48537ff56 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -35,7 +35,12 @@ class WifiNetwork: class Device(ABC): - """Common device interface.""" + """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,