diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 1e0f17f7..8372bfff 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -6,15 +6,17 @@ This script can be used to create fixture files for individual modules. import asyncio import json from pathlib import Path +from typing import cast import typer -from kasa import Discover, SmartDevice +from kasa import Discover +from kasa.iot import IotDevice app = typer.Typer() -def create_fixtures(dev: SmartDevice, outputdir: Path): +def create_fixtures(dev: IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): module_dir = outputdir / name @@ -43,13 +45,14 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: SmartDevice = asyncio.run(Discover.discover_single(host)) + dev: IotDevice = cast(IotDevice, asyncio.run(Discover.discover_single(host))) devs.append(dev) else: if network is None: network = "255.255.255.255" devs = asyncio.run(Discover.discover(target=network)).values() for dev in devs: + dev = cast(IotDevice, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 005eb799..c1436aa1 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -23,14 +23,14 @@ from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest from kasa import ( AuthenticationException, Credentials, + Device, Discover, - SmartDevice, SmartDeviceException, TimeoutException, ) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode -from kasa.tapo.tapodevice import TapoDevice +from kasa.smart import SmartDevice Call = namedtuple("Call", "module method") SmartCall = namedtuple("SmartCall", "module request should_succeed") @@ -119,9 +119,9 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: SmartDevice, batch_size: int): +async def handle_device(basedir, autosave, device: Device, batch_size: int): """Create a fixture for a single device instance.""" - if isinstance(device, TapoDevice): + if isinstance(device, SmartDevice): filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -319,7 +319,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: TapoDevice, batch_size: int): +async def get_smart_fixture(device: SmartDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/__init__.py b/kasa/__init__.py index 121413b6..0d9e0c3e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,9 +12,13 @@ Module-specific errors are raised as `SmartDeviceException` and are expected to be handled by the user of the library. """ from importlib.metadata import version +from typing import TYPE_CHECKING from warnings import warn +from kasa.bulb import Bulb from kasa.credentials import Credentials +from kasa.device import Device +from kasa.device_type import DeviceType from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -29,18 +33,14 @@ from kasa.exceptions import ( TimeoutException, UnsupportedDeviceException, ) +from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) +from kasa.plug import Plug from kasa.protocol import BaseProtocol -from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors -from kasa.smartdevice import DeviceType, SmartDevice -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug from kasa.smartprotocol import SmartProtocol -from kasa.smartstrip import SmartStrip __version__ = version("python-kasa") @@ -50,18 +50,15 @@ __all__ = [ "BaseProtocol", "IotProtocol", "SmartProtocol", - "SmartBulb", - "SmartBulbPreset", + "BulbPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "EmeterStatus", - "SmartDevice", + "Device", + "Bulb", + "Plug", "SmartDeviceException", - "SmartPlug", - "SmartStrip", - "SmartDimmer", - "SmartLightStrip", "AuthenticationException", "UnsupportedDeviceException", "TimeoutException", @@ -72,11 +69,55 @@ __all__ = [ "DeviceFamilyType", ] +from . import iot + deprecated_names = ["TPLinkSmartHomeProtocol"] +deprecated_smart_devices = { + "SmartDevice": iot.IotDevice, + "SmartPlug": iot.IotPlug, + "SmartBulb": iot.IotBulb, + "SmartLightStrip": iot.IotLightStrip, + "SmartStrip": iot.IotStrip, + "SmartDimmer": iot.IotDimmer, + "SmartBulbPreset": BulbPreset, +} def __getattr__(name): if name in deprecated_names: warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) return globals()[f"_deprecated_{name}"] + if name in deprecated_smart_devices: + new_class = deprecated_smart_devices[name] + package_name = ".".join(new_class.__module__.split(".")[:-1]) + warn( + f"{name} is deprecated, use {new_class.__name__} " + + f"from package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", + DeprecationWarning, + stacklevel=1, + ) + return new_class raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +if TYPE_CHECKING: + SmartDevice = Device + SmartBulb = iot.IotBulb + SmartPlug = iot.IotPlug + SmartLightStrip = iot.IotLightStrip + SmartStrip = iot.IotStrip + SmartDimmer = iot.IotDimmer + SmartBulbPreset = BulbPreset + # Instanstiate all classes so the type checkers catch abstract issues + from . import smart + + smart.SmartDevice("127.0.0.1") + smart.SmartPlug("127.0.0.1") + smart.SmartBulb("127.0.0.1") + iot.IotDevice("127.0.0.1") + iot.IotPlug("127.0.0.1") + iot.IotBulb("127.0.0.1") + iot.IotLightStrip("127.0.0.1") + iot.IotStrip("127.0.0.1") + iot.IotDimmer("127.0.0.1") diff --git a/kasa/bulb.py b/kasa/bulb.py new file mode 100644 index 00000000..5db6e5b7 --- /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 04f16fbd..74c32e4e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,21 +13,20 @@ import asyncclick as click from kasa import ( AuthenticationException, + Bulb, ConnectionType, Credentials, + Device, DeviceConfig, DeviceFamilyType, Discover, EncryptType, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, + SmartDeviceException, UnsupportedDeviceException, ) from kasa.discover import DiscoveryResult +from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip +from kasa.smart import SmartBulb, SmartDevice, SmartPlug try: from pydantic.v1 import ValidationError @@ -62,11 +61,18 @@ echo = _do_echo TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, + "plug": IotPlug, + "bulb": IotBulb, + "dimmer": IotDimmer, + "strip": IotStrip, + "lightstrip": IotLightStrip, + "iot.plug": IotPlug, + "iot.bulb": IotBulb, + "iot.dimmer": IotDimmer, + "iot.strip": IotStrip, + "iot.lightstrip": IotLightStrip, + "smart.plug": SmartPlug, + "smart.bulb": SmartBulb, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -80,7 +86,7 @@ SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"] click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(SmartDevice) +pass_dev = click.make_pass_decorator(Device) class ExceptionHandlerGroup(click.Group): @@ -110,8 +116,8 @@ def json_formatter_cb(result, **kwargs): """ return str(val) - @to_serializable.register(SmartDevice) - def _device_to_serializable(val: SmartDevice): + @to_serializable.register(Device) + def _device_to_serializable(val: Device): """Serialize smart device data, just using the last update raw payload.""" return val.internal_state @@ -261,7 +267,7 @@ async def cli( # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": # Context object is required to avoid crashing on sub-groups - ctx.obj = SmartDevice(None) + ctx.obj = Device(None) return # If JSON output is requested, disable echo @@ -340,7 +346,7 @@ async def cli( timeout=timeout, connection_type=ctype, ) - dev = await SmartDevice.connect(config=config) + dev = await Device.connect(config=config) else: echo("No --type or --device-family and --encrypt-type defined, discovering..") dev = await Discover.discover_single( @@ -384,7 +390,7 @@ async def scan(dev): @click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) @pass_dev -async def join(dev: SmartDevice, ssid: str, password: str, keytype: str): +async def join(dev: Device, ssid: str, password: str, keytype: str): """Join the given wifi network.""" echo(f"Asking the device to connect to {ssid}..") res = await dev.wifi_join(ssid, password, keytype=keytype) @@ -428,7 +434,7 @@ async def discover(ctx): echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - async def print_discovered(dev: SmartDevice): + async def print_discovered(dev: Device): async with sem: try: await dev.update() @@ -526,7 +532,7 @@ async def sysinfo(dev): @cli.command() @pass_dev @click.pass_context -async def state(ctx, dev: SmartDevice): +async def state(ctx, dev: Device): """Print out device state and versions.""" verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False @@ -589,7 +595,6 @@ async def alias(dev, new_alias, index): if not dev.is_strip: echo("Index can only used for power strips!") return - dev = cast(SmartStrip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -611,7 +616,7 @@ async def alias(dev, new_alias, index): @click.argument("module") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def raw_command(ctx, dev: SmartDevice, module, command, parameters): +async def raw_command(ctx, dev: Device, module, command, parameters): """Run a raw command on the device.""" logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) return await ctx.forward(cmd_command) @@ -622,12 +627,17 @@ async def raw_command(ctx, dev: SmartDevice, module, command, parameters): @click.option("--module", required=False, help="Module for IOT protocol.") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def cmd_command(dev: SmartDevice, module, command, parameters): +async def cmd_command(dev: Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) - res = await dev._query_helper(module, command, parameters) + if isinstance(dev, IotDevice): + res = await dev._query_helper(module, command, parameters) + elif isinstance(dev, SmartDevice): + res = await dev._query_helper(command, parameters) + else: + raise SmartDeviceException("Unexpected device type %s.", dev) echo(json.dumps(res)) return res @@ -639,7 +649,7 @@ async def cmd_command(dev: SmartDevice, module, command, parameters): @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): +async def emeter(dev: Device, index: int, name: str, year, month, erase): """Query emeter for historical consumption. Daily and monthly data provided in CSV format. @@ -649,7 +659,6 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -660,6 +669,12 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): echo("Device has no emeter") return + if (year or month or erase) and not isinstance(dev, IotDevice): + echo("Device has no historical statistics") + return + else: + dev = cast(IotDevice, dev) + if erase: echo("Erasing emeter statistics..") return await dev.erase_emeter_stats() @@ -701,7 +716,7 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def usage(dev: SmartDevice, year, month, erase): +async def usage(dev: Device, year, month, erase): """Query usage for historical consumption. Daily and monthly data provided in CSV format. @@ -739,7 +754,7 @@ async def usage(dev: SmartDevice, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: SmartBulb, brightness: int, transition: int): +async def brightness(dev: Bulb, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -759,7 +774,7 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: SmartBulb, temperature: int, transition: int): +async def temperature(dev: Bulb, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") @@ -852,14 +867,13 @@ async def time(dev): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def on(dev: SmartDevice, index: int, name: str, transition: int): +async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -874,14 +888,13 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def off(dev: SmartDevice, index: int, name: str, transition: int): +async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -896,14 +909,13 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def toggle(dev: SmartDevice, index: int, name: str, transition: int): +async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -970,10 +982,10 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: SmartBulb): +def presets_list(dev: IotBulb): """List presets.""" - if not dev.is_bulb: - echo("Presets only supported on bulbs") + if not dev.is_bulb or not isinstance(dev, IotBulb): + echo("Presets only supported on iot bulbs") return for preset in dev.presets: @@ -989,9 +1001,7 @@ def presets_list(dev: SmartBulb): @click.option("--saturation", type=int) @click.option("--temperature", type=int) @pass_dev -async def presets_modify( - dev: SmartBulb, index, brightness, hue, saturation, temperature -): +async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: if preset.index == index: @@ -1019,8 +1029,11 @@ async def presets_modify( @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--last", is_flag=True) @click.option("--preset", type=int) -async def turn_on_behavior(dev: SmartBulb, type, last, preset): +async def turn_on_behavior(dev: IotBulb, type, last, preset): """Modify bulb turn-on behavior.""" + if not dev.is_bulb or not isinstance(dev, IotBulb): + echo("Presets only supported on iot bulbs") + return settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1055,10 +1068,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): ) async def update_credentials(dev, username, password): """Update device credentials for authenticated devices.""" - # Importing here as this is not really a public interface for now - from kasa.tapo import TapoDevice - - if not isinstance(dev, TapoDevice): + if not isinstance(dev, SmartDevice): raise NotImplementedError( "Credentials can only be updated on authenticated devices." ) diff --git a/kasa/device.py b/kasa/device.py new file mode 100644 index 00000000..48537ff5 --- /dev/null +++ b/kasa/device.py @@ -0,0 +1,353 @@ +"""Module for Device base class.""" +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Set, Union + +from .credentials import Credentials +from .device_type import DeviceType +from .deviceconfig import DeviceConfig +from .emeterstatus import EmeterStatus +from .exceptions import SmartDeviceException +from .iotprotocol import IotProtocol +from .protocol import BaseProtocol +from .xortransport import XorTransport + + +@dataclass +class WifiNetwork: + """Wifi network container.""" + + ssid: str + key_type: int + # These are available only on softaponboarding + cipher_type: Optional[int] = None + bssid: Optional[str] = None + channel: Optional[int] = None + rssi: Optional[int] = None + + # For SMART devices + signal_level: Optional[int] = None + + +_LOGGER = logging.getLogger(__name__) + + +class Device(ABC): + """Common device interface. + + Do not instantiate this class directly, instead get a device instance from + :func:`Device.connect()`, :func:`Discover.discover()` + or :func:`Discover.discover_single()`. + """ + + def __init__( + self, + host: str, + *, + config: Optional[DeviceConfig] = None, + protocol: Optional[BaseProtocol] = None, + ) -> None: + """Create a new Device instance. + + :param str host: host name or IP address of the device + :param DeviceConfig config: device configuration + :param BaseProtocol protocol: protocol for communicating with the device + """ + if config and protocol: + protocol._transport._config = config + self.protocol: BaseProtocol = protocol or IotProtocol( + transport=XorTransport(config=config or DeviceConfig(host=host)), + ) + _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) + self._device_type = DeviceType.Unknown + # TODO: typing Any is just as using Optional[Dict] would require separate + # checks in accessors. the @updated_required decorator does not ensure + # mypy that these are not accessed incorrectly. + self._last_update: Any = None + self._discovery_info: Optional[Dict[str, Any]] = None + + self.modules: Dict[str, Any] = {} + + @staticmethod + async def connect( + *, + host: Optional[str] = None, + config: Optional[DeviceConfig] = None, + ) -> "Device": + """Connect to a single device by the given hostname or device configuration. + + This method avoids the UDP based discovery process and + will connect directly to the device. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + :param host: Hostname of device to query + :param config: Connection parameters to ensure the correct protocol + and connection options are used. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + from .device_factory import connect # pylint: disable=import-outside-toplevel + + return await connect(host=host, config=config) # type: ignore[arg-type] + + @abstractmethod + async def update(self, update_children: bool = True): + """Update the device.""" + + async def disconnect(self): + """Disconnect and close any underlying connection resources.""" + await self.protocol.close() + + @property + @abstractmethod + def is_on(self) -> bool: + """Return true if the device is on.""" + + @property + def is_off(self) -> bool: + """Return True if device is off.""" + return not self.is_on + + @abstractmethod + async def turn_on(self, **kwargs) -> Optional[Dict]: + """Turn on the device.""" + + @abstractmethod + async def turn_off(self, **kwargs) -> Optional[Dict]: + """Turn off the device.""" + + @property + def host(self) -> str: + """The device host.""" + return self.protocol._transport._host + + @host.setter + def host(self, value): + """Set the device host. + + Generally used by discovery to set the hostname after ip discovery. + """ + self.protocol._transport._host = value + self.protocol._transport._config.host = value + + @property + def port(self) -> int: + """The device port.""" + return self.protocol._transport._port + + @property + def credentials(self) -> Optional[Credentials]: + """The device credentials.""" + return self.protocol._transport._credentials + + @property + def credentials_hash(self) -> Optional[str]: + """The protocol specific hash of the credentials the device is using.""" + return self.protocol._transport.credentials_hash + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + return self._device_type + + @abstractmethod + def update_from_discover_info(self, info): + """Update state from info from the discover call.""" + + @property + def config(self) -> DeviceConfig: + """Return the device configuration.""" + return self.protocol.config + + @property + @abstractmethod + def model(self) -> str: + """Returns the device model.""" + + @property + @abstractmethod + def alias(self) -> Optional[str]: + """Returns the device alias or nickname.""" + + async def _raw_query(self, request: Union[str, Dict]) -> Any: + """Send a raw query to the device.""" + return await self.protocol.query(request=request) + + @property + @abstractmethod + def children(self) -> Sequence["Device"]: + """Returns the child devices.""" + + @property + @abstractmethod + def sys_info(self) -> Dict[str, Any]: + """Returns the device info.""" + + @property + def is_bulb(self) -> bool: + """Return True if the device is a bulb.""" + return self._device_type == DeviceType.Bulb + + @property + def is_light_strip(self) -> bool: + """Return True if the device is a led strip.""" + return self._device_type == DeviceType.LightStrip + + @property + def is_plug(self) -> bool: + """Return True if the device is a plug.""" + return self._device_type == DeviceType.Plug + + @property + def is_strip(self) -> bool: + """Return True if the device is a strip.""" + return self._device_type == DeviceType.Strip + + @property + def is_strip_socket(self) -> bool: + """Return True if the device is a strip socket.""" + return self._device_type == DeviceType.StripSocket + + @property + def is_dimmer(self) -> bool: + """Return True if the device is a dimmer.""" + return self._device_type == DeviceType.Dimmer + + @property + def is_dimmable(self) -> bool: + """Return True if the device is dimmable.""" + return False + + @property + def is_variable_color_temp(self) -> bool: + """Return True if the device supports color temperature.""" + return False + + @property + def is_color(self) -> bool: + """Return True if the device supports color changes.""" + return False + + def get_plug_by_name(self, name: str) -> "Device": + """Return child device for the given name.""" + for p in self.children: + if p.alias == name: + return p + + raise SmartDeviceException(f"Device has no child with {name}") + + def get_plug_by_index(self, index: int) -> "Device": + """Return child device for the given index.""" + if index + 1 > len(self.children) or index < 0: + raise SmartDeviceException( + f"Invalid index {index}, device has {len(self.children)} plugs" + ) + return self.children[index] + + @property + @abstractmethod + def time(self) -> datetime: + """Return the time.""" + + @property + @abstractmethod + def timezone(self) -> Dict: + """Return the timezone and time_difference.""" + + @property + @abstractmethod + def hw_info(self) -> Dict: + """Return hardware info for the device.""" + + @property + @abstractmethod + def location(self) -> Dict: + """Return the device location.""" + + @property + @abstractmethod + def rssi(self) -> Optional[int]: + """Return the rssi.""" + + @property + @abstractmethod + def mac(self) -> str: + """Return the mac formatted with colons.""" + + @property + @abstractmethod + def device_id(self) -> str: + """Return the device id.""" + + @property + @abstractmethod + def internal_state(self) -> Any: + """Return all the internal state data.""" + + @property + @abstractmethod + def state_information(self) -> Dict[str, Any]: + """Return the key state information.""" + + @property + @abstractmethod + def features(self) -> Set[str]: + """Return the list of supported features.""" + + @property + @abstractmethod + def has_emeter(self) -> bool: + """Return if the device has emeter.""" + + @property + @abstractmethod + def on_since(self) -> Optional[datetime]: + """Return the time that the device was turned on or None if turned off.""" + + @abstractmethod + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + + @property + @abstractmethod + def emeter_realtime(self) -> EmeterStatus: + """Get the emeter status.""" + + @property + @abstractmethod + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + + @property + @abstractmethod + def emeter_today(self) -> Union[Optional[float], Any]: + """Get the emeter value for today.""" + # Return type of Any ensures consumers being shielded from the return + # type by @update_required are not affected. + + @abstractmethod + async def wifi_scan(self) -> List[WifiNetwork]: + """Scan for available wifi networks.""" + + @abstractmethod + async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): + """Join the given wifi network.""" + + @abstractmethod + async def set_alias(self, alias: str): + """Set the device name (alias).""" + + def __repr__(self): + if self._last_update is None: + return f"<{self._device_type} at {self.host} - update() needed>" + return ( + f"<{self._device_type} model {self.model} at {self.host}" + f" ({self.alias}), is_on: {self.is_on}" + f" - dev specific: {self.state_information}>" + ) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index fdb5b1b4..28a5e3b2 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -4,22 +4,18 @@ import time from typing import Any, Dict, Optional, Tuple, Type from .aestransport import AesTransport +from .device import Device from .deviceconfig import DeviceConfig from .exceptions import SmartDeviceException, UnsupportedDeviceException +from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( BaseProtocol, BaseTransport, ) -from .smartbulb import SmartBulb -from .smartdevice import SmartDevice -from .smartdimmer import SmartDimmer -from .smartlightstrip import SmartLightStrip -from .smartplug import SmartPlug +from .smart import SmartBulb, SmartPlug from .smartprotocol import SmartProtocol -from .smartstrip import SmartStrip -from .tapo import TapoBulb, TapoPlug from .xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -29,7 +25,7 @@ GET_SYSINFO_QUERY = { } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": +async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Device": """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -73,7 +69,8 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart + f"{config.connection_type.device_family.value}" ) - device_class: Optional[Type[SmartDevice]] + device_class: Optional[Type[Device]] + device: Optional[Device] = None if isinstance(protocol, IotProtocol) and isinstance( protocol._transport, XorTransport @@ -100,7 +97,7 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: +def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") @@ -111,32 +108,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: raise SmartDeviceException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return SmartDimmer + return IotDimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return SmartStrip + return IotStrip - return SmartPlug + return IotPlug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return SmartLightStrip + return IotLightStrip - return SmartBulb + return IotBulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) -def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: +def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: """Return the device class from the type name.""" - supported_device_types: Dict[str, Type[SmartDevice]] = { - "SMART.TAPOPLUG": TapoPlug, - "SMART.TAPOBULB": TapoBulb, - "SMART.TAPOSWITCH": TapoBulb, - "SMART.KASAPLUG": TapoPlug, - "SMART.KASASWITCH": TapoBulb, - "IOT.SMARTPLUGSWITCH": SmartPlug, - "IOT.SMARTBULB": SmartBulb, + supported_device_types: Dict[str, Type[Device]] = { + "SMART.TAPOPLUG": SmartPlug, + "SMART.TAPOBULB": SmartBulb, + "SMART.TAPOSWITCH": SmartBulb, + "SMART.KASAPLUG": SmartPlug, + "SMART.KASASWITCH": SmartBulb, + "IOT.SMARTPLUGSWITCH": IotPlug, + "IOT.SMARTBULB": IotBulb, } return supported_device_types.get(device_type) diff --git a/kasa/device_type.py b/kasa/device_type.py index 8373d730..162fc4f2 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 8286387a..858109e2 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,6 +15,7 @@ try: except ImportError: from pydantic import BaseModel, ValidationError # pragma: no cover +from kasa import Device from kasa.credentials import Credentials from kasa.device_factory import ( get_device_class_from_family, @@ -22,17 +23,21 @@ from kasa.device_factory import ( get_protocol, ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType -from kasa.exceptions import TimeoutException, UnsupportedDeviceException +from kasa.exceptions import ( + SmartDeviceException, + TimeoutException, + UnsupportedDeviceException, +) +from kasa.iot.iotdevice import IotDevice from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) -OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] -DeviceDict = Dict[str, SmartDevice] +OnDiscoveredCallable = Callable[[Device], Awaitable[None]] +DeviceDict = Dict[str, Device] class _DiscoverProtocol(asyncio.DatagramProtocol): @@ -121,7 +126,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): return self.seen_hosts.add(ip) - device = None + device: Optional[Device] = None config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: @@ -300,7 +305,7 @@ class Discover: port: Optional[int] = None, timeout: Optional[int] = None, credentials: Optional[Credentials] = None, - ) -> SmartDevice: + ) -> Device: """Discover a single device by the given IP address. It is generally preferred to avoid :func:`discover_single()` and @@ -382,7 +387,7 @@ class Discover: raise SmartDeviceException(f"Unable to get discovery response for {host}") @staticmethod - def _get_device_class(info: dict) -> Type[SmartDevice]: + def _get_device_class(info: dict) -> Type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult(**info["result"]) @@ -397,7 +402,7 @@ class Discover: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: + def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: """Get SmartDevice from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) @@ -408,7 +413,7 @@ class Discover: _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) - device_class = Discover._get_device_class(info) + device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) sys_info = info["system"]["get_sysinfo"] if device_type := sys_info.get("mic_type", sys_info.get("type")): @@ -423,7 +428,7 @@ class Discover: def _get_device_instance( data: bytes, config: DeviceConfig, - ) -> SmartDevice: + ) -> Device: """Get SmartDevice from the new 20002 response.""" try: info = json_loads(data[16:]) diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py new file mode 100644 index 00000000..2ee03d69 --- /dev/null +++ b/kasa/iot/__init__.py @@ -0,0 +1,16 @@ +"""Package for supporting legacy kasa devices.""" +from .iotbulb import IotBulb +from .iotdevice import IotDevice +from .iotdimmer import IotDimmer +from .iotlightstrip import IotLightStrip +from .iotplug import IotPlug +from .iotstrip import IotStrip + +__all__ = [ + "IotDevice", + "IotPlug", + "IotBulb", + "IotStrip", + "IotDimmer", + "IotLightStrip", +] diff --git a/kasa/smartbulb.py b/kasa/iot/iotbulb.py similarity index 91% rename from kasa/smartbulb.py rename to kasa/iot/iotbulb.py index 5b5ae573..7712f3d7 100644 --- a/kasa/smartbulb.py +++ b/kasa/iot/iotbulb.py @@ -2,49 +2,19 @@ import logging import re from enum import Enum -from typing import Any, Dict, List, NamedTuple, Optional, cast +from typing import Any, Dict, List, Optional, cast try: from pydantic.v1 import BaseModel, Field, root_validator except ImportError: from pydantic import BaseModel, Field, root_validator -from .deviceconfig import DeviceConfig +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import IotDevice, SmartDeviceException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol -from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update - - -class ColorTempRange(NamedTuple): - """Color temperature range.""" - - min: int - max: int - - -class HSV(NamedTuple): - """Hue-saturation-value.""" - - hue: int - saturation: int - value: int - - -class SmartBulbPreset(BaseModel): - """Bulb configuration preset.""" - - index: int - brightness: int - - # These are not available for effect mode presets on light strips - hue: Optional[int] - saturation: Optional[int] - color_temp: Optional[int] - - # Variables for effect mode presets - custom: Optional[int] - id: Optional[str] - mode: Optional[int] class BehaviorMode(str, Enum): @@ -116,7 +86,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _LOGGER = logging.getLogger(__name__) -class SmartBulb(SmartDevice): +class IotBulb(IotDevice, Bulb): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -132,7 +102,7 @@ class SmartBulb(SmartDevice): Examples: >>> import asyncio - >>> bulb = SmartBulb("127.0.0.1") + >>> bulb = IotBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) Bulb2 @@ -198,7 +168,7 @@ class SmartBulb(SmartDevice): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + [BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: @@ -373,10 +343,6 @@ class SmartBulb(SmartDevice): return HSV(hue, saturation, value) - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") - @requires_update async def set_hsv( self, @@ -534,11 +500,11 @@ class SmartBulb(SmartDevice): @property # type: ignore @requires_update - def presets(self) -> List[SmartBulbPreset]: + def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" - return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] - async def save_preset(self, preset: SmartBulbPreset): + async def save_preset(self, preset: BulbPreset): """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/smartdevice.py b/kasa/iot/iotdevice.py similarity index 76% rename from kasa/smartdevice.py rename to kasa/iot/iotdevice.py index 01ca382d..8e51cac6 100755 --- a/kasa/smartdevice.py +++ b/kasa/iot/iotdevice.py @@ -15,39 +15,19 @@ import collections.abc import functools import inspect import logging -from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Sequence, Set -from .credentials import Credentials -from .device_type import DeviceType -from .deviceconfig import DeviceConfig -from .emeterstatus import EmeterStatus -from .exceptions import SmartDeviceException -from .iotprotocol import IotProtocol -from .modules import Emeter, Module -from .protocol import BaseProtocol -from .xortransport import XorTransport +from ..device import Device, WifiNetwork +from ..deviceconfig import DeviceConfig +from ..emeterstatus import EmeterStatus +from ..exceptions import SmartDeviceException +from ..protocol import BaseProtocol +from .modules import Emeter, IotModule _LOGGER = logging.getLogger(__name__) -@dataclass -class WifiNetwork: - """Wifi network container.""" - - ssid: str - key_type: int - # These are available only on softaponboarding - cipher_type: Optional[int] = None - bssid: Optional[str] = None - channel: Optional[int] = None - rssi: Optional[int] = None - - # For SMART devices - signal_level: Optional[int] = None - - def merge(d, u): """Update dict recursively.""" for k, v in u.items(): @@ -92,17 +72,17 @@ def _parse_features(features: str) -> Set[str]: return set(features.split(":")) -class SmartDevice: +class IotDevice(Device): """Base class for all supported device types. You don't usually want to initialize this class manually, but either use :class:`Discover` class, or use one of the subclasses: - * :class:`SmartPlug` - * :class:`SmartBulb` - * :class:`SmartStrip` - * :class:`SmartDimmer` - * :class:`SmartLightStrip` + * :class:`IotPlug` + * :class:`IotBulb` + * :class:`IotStrip` + * :class:`IotDimmer` + * :class:`IotLightStrip` To initialize, you have to await :func:`update()` at least once. This will allow accessing the properties using the exposed properties. @@ -115,7 +95,7 @@ class SmartDevice: Examples: >>> import asyncio - >>> dev = SmartDevice("127.0.0.1") + >>> dev = IotDevice("127.0.0.1") >>> asyncio.run(dev.update()) All devices provide several informational properties: @@ -200,59 +180,24 @@ class SmartDevice: config: Optional[DeviceConfig] = None, protocol: Optional[BaseProtocol] = None, ) -> None: - """Create a new SmartDevice instance. - - :param str host: host name or ip address on which the device listens - """ - if config and protocol: - protocol._transport._config = config - self.protocol: BaseProtocol = protocol or IotProtocol( - transport=XorTransport(config=config or DeviceConfig(host=host)), - ) - _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) - self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate - # checks in accessors. the @updated_required decorator does not ensure - # mypy that these are not accessed incorrectly. - self._last_update: Any = None - self._discovery_info: Optional[Dict[str, Any]] = None + """Create a new IotDevice instance.""" + super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() - self.modules: Dict[str, Any] = {} - - self.children: List["SmartDevice"] = [] + self._children: Sequence["IotDevice"] = [] @property - def host(self) -> str: - """The device host.""" - return self.protocol._transport._host + def children(self) -> Sequence["IotDevice"]: + """Return list of children.""" + return self._children - @host.setter - def host(self, value): - """Set the device host. + @children.setter + def children(self, children): + """Initialize from a list of children.""" + self._children = children - Generally used by discovery to set the hostname after ip discovery. - """ - self.protocol._transport._host = value - self.protocol._transport._config.host = value - - @property - def port(self) -> int: - """The device port.""" - return self.protocol._transport._port - - @property - def credentials(self) -> Optional[Credentials]: - """The device credentials.""" - return self.protocol._transport._credentials - - @property - def credentials_hash(self) -> Optional[str]: - """The protocol specific hash of the credentials the device is using.""" - return self.protocol._transport.credentials_hash - - def add_module(self, name: str, module: Module): + def add_module(self, name: str, module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) @@ -291,7 +236,7 @@ class SmartDevice: request = self._create_request(target, cmd, arg, child_ids) try: - response = await self.protocol.query(request=request) + response = await self._raw_query(request=request) except Exception as ex: raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex @@ -631,13 +576,7 @@ class SmartDevice: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") - @property # type: ignore - @requires_update - def is_off(self) -> bool: - """Return True if device is off.""" - return not self.is_on - - async def turn_on(self, **kwargs) -> Dict: + async def turn_on(self, **kwargs) -> Optional[Dict]: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -714,77 +653,11 @@ class SmartDevice: ) return await _join("smartlife.iot.common.softaponboarding", payload) - def get_plug_by_name(self, name: str) -> "SmartDevice": - """Return child device for the given name.""" - for p in self.children: - if p.alias == name: - return p - - raise SmartDeviceException(f"Device has no child with {name}") - - def get_plug_by_index(self, index: int) -> "SmartDevice": - """Return child device for the given index.""" - if index + 1 > len(self.children) or index < 0: - raise SmartDeviceException( - f"Invalid index {index}, device has {len(self.children)} plugs" - ) - return self.children[index] - @property def max_device_response_size(self) -> int: """Returns the maximum response size the device can safely construct.""" return 16 * 1024 - @property - def device_type(self) -> DeviceType: - """Return the device type.""" - return self._device_type - - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self._device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self._device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self._device_type == DeviceType.Plug - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self._device_type == DeviceType.Strip - - @property - def is_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self._device_type == DeviceType.StripSocket - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self._device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - @property def internal_state(self) -> Any: """Return the internal state of the instance. @@ -793,47 +666,3 @@ class SmartDevice: This should only be used for debugging purposes. """ return self._last_update or self._discovery_info - - def __repr__(self): - if self._last_update is None: - return f"<{self._device_type} at {self.host} - update() needed>" - return ( - f"<{self._device_type} model {self.model} at {self.host}" - f" ({self.alias}), is_on: {self.is_on}" - f" - dev specific: {self.state_information}>" - ) - - @property - def config(self) -> DeviceConfig: - """Return the device configuration.""" - return self.protocol.config - - async def disconnect(self): - """Disconnect and close any underlying connection resources.""" - await self.protocol.close() - - @staticmethod - async def connect( - *, - host: Optional[str] = None, - config: Optional[DeviceConfig] = None, - ) -> "SmartDevice": - """Connect to a single device by the given hostname or device configuration. - - This method avoids the UDP based discovery process and - will connect directly to the device. - - It is generally preferred to avoid :func:`discover_single()` and - use this function instead as it should perform better when - the WiFi network is congested or the device is not responding - to discovery requests. - - :param host: Hostname of device to query - :param config: Connection parameters to ensure the correct protocol - and connection options are used. - :rtype: SmartDevice - :return: Object for querying/controlling found device. - """ - from .device_factory import connect # pylint: disable=import-outside-toplevel - - return await connect(host=host, config=config) # type: ignore[arg-type] diff --git a/kasa/smartdimmer.py b/kasa/iot/iotdimmer.py similarity index 95% rename from kasa/smartdimmer.py rename to kasa/iot/iotdimmer.py index 97738cc4..b7b727eb 100644 --- a/kasa/smartdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -2,11 +2,12 @@ from enum import Enum from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.modules import AmbientLight, Motion -from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update -from kasa.smartplug import SmartPlug +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import SmartDeviceException, requires_update +from .iotplug import IotPlug +from .modules import AmbientLight, Motion class ButtonAction(Enum): @@ -32,7 +33,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class SmartDimmer(SmartPlug): +class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -50,7 +51,7 @@ class SmartDimmer(SmartPlug): Examples: >>> import asyncio - >>> dimmer = SmartDimmer("192.168.1.105") + >>> dimmer = IotDimmer("192.168.1.105") >>> asyncio.run(dimmer.turn_on()) >>> dimmer.brightness 25 diff --git a/kasa/smartlightstrip.py b/kasa/iot/iotlightstrip.py similarity index 92% rename from kasa/smartlightstrip.py rename to kasa/iot/iotlightstrip.py index 103ecfa8..942b9f78 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -1,14 +1,15 @@ """Module for light strips (KL430).""" from typing import Any, Dict, List, Optional -from .deviceconfig import DeviceConfig -from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 -from .protocol import BaseProtocol -from .smartbulb import SmartBulb -from .smartdevice import DeviceType, SmartDeviceException, requires_update +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..protocol import BaseProtocol +from .iotbulb import IotBulb +from .iotdevice import SmartDeviceException, requires_update -class SmartLightStrip(SmartBulb): +class IotLightStrip(IotBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -17,7 +18,7 @@ class SmartLightStrip(SmartBulb): Examples: >>> import asyncio - >>> strip = SmartLightStrip("127.0.0.1") + >>> strip = IotLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> print(strip.alias) KL430 pantry lightstrip diff --git a/kasa/smartplug.py b/kasa/iot/iotplug.py similarity index 90% rename from kasa/smartplug.py rename to kasa/iot/iotplug.py index e8251b68..72cba7c3 100644 --- a/kasa/smartplug.py +++ b/kasa/iot/iotplug.py @@ -2,15 +2,16 @@ import logging from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDevice, requires_update +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import IotDevice, requires_update +from .modules import Antitheft, Cloud, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) -class SmartPlug(SmartDevice): +class IotPlug(IotDevice): r"""Representation of a TP-Link Smart Switch. To initialize, you have to await :func:`update()` at least once. @@ -25,7 +26,7 @@ class SmartPlug(SmartDevice): Examples: >>> import asyncio - >>> plug = SmartPlug("127.0.0.1") + >>> plug = IotPlug("127.0.0.1") >>> asyncio.run(plug.update()) >>> plug.alias Kitchen diff --git a/kasa/smartstrip.py b/kasa/iot/iotstrip.py similarity index 95% rename from kasa/smartstrip.py rename to kasa/iot/iotstrip.py index b1e967c4..7cbb10b0 100755 --- a/kasa/smartstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,19 +4,18 @@ from collections import defaultdict from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional -from kasa.smartdevice import ( - DeviceType, +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import SmartDeviceException +from ..protocol import BaseProtocol +from .iotdevice import ( EmeterStatus, - SmartDevice, - SmartDeviceException, + IotDevice, merge, requires_update, ) -from kasa.smartplug import SmartPlug - -from .deviceconfig import DeviceConfig +from .iotplug import IotPlug from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ def merge_sums(dicts): return total_dict -class SmartStrip(SmartDevice): +class IotStrip(IotDevice): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. @@ -49,7 +48,7 @@ class SmartStrip(SmartDevice): Examples: >>> import asyncio - >>> strip = SmartStrip("127.0.0.1") + >>> strip = IotStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> strip.alias TP-LINK_Power Strip_CF69 @@ -116,10 +115,10 @@ class SmartStrip(SmartDevice): if not self.children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) - for child in children: - self.children.append( - SmartStripPlug(self.host, parent=self, child_id=child["id"]) - ) + self.children = [ + IotStripPlug(self.host, parent=self, child_id=child["id"]) + for child in children + ] if update_children and self.has_emeter: for plug in self.children: @@ -244,7 +243,7 @@ class SmartStrip(SmartDevice): return EmeterStatus(emeter) -class SmartStripPlug(SmartPlug): +class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. This allows you to use the sockets as they were SmartPlug objects. @@ -254,7 +253,7 @@ class SmartStripPlug(SmartPlug): The plug inherits (most of) the system information from the parent. """ - def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: + def __init__(self, host: str, parent: "IotStrip", child_id: str) -> None: super().__init__(host) self.parent = parent diff --git a/kasa/modules/__init__.py b/kasa/iot/modules/__init__.py similarity index 91% rename from kasa/modules/__init__.py rename to kasa/iot/modules/__init__.py index 8ad5088d..17a34b6e 100644 --- a/kasa/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,7 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import Module +from .module import IotModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +17,7 @@ __all__ = [ "Cloud", "Countdown", "Emeter", - "Module", + "IotModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py similarity index 96% rename from kasa/modules/ambientlight.py rename to kasa/iot/modules/ambientlight.py index 963c73a3..0a766367 100644 --- a/kasa/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 IotModule # TODO create tests and use the config reply there # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, @@ -11,7 +11,7 @@ from .module import Module # {"name":"custom","adc":2400,"value":97}]}] -class AmbientLight(Module): +class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" def query(self): 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 96% rename from kasa/modules/cloud.py rename to kasa/iot/modules/cloud.py index b4eface5..28cf2d1e 100644 --- a/kasa/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,7 +4,7 @@ try: except ImportError: from pydantic import BaseModel -from .module import Module +from .module import IotModule class CloudInfo(BaseModel): @@ -22,7 +22,7 @@ class CloudInfo(BaseModel): username: str -class Cloud(Module): +class Cloud(IotModule): """Module implementing support for cloud services.""" def query(self): 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 11eed48f..1570519e 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/modules/module.py b/kasa/iot/modules/module.py similarity index 92% rename from kasa/modules/module.py rename to kasa/iot/modules/module.py index 40890f29..51d4b350 100644 --- a/kasa/modules/module.py +++ b/kasa/iot/modules/module.py @@ -4,10 +4,10 @@ import logging from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from ..exceptions import SmartDeviceException +from ...exceptions import SmartDeviceException if TYPE_CHECKING: - from kasa import SmartDevice + from kasa.iot import IotDevice _LOGGER = logging.getLogger(__name__) @@ -24,15 +24,15 @@ def merge(d, u): return d -class Module(ABC): +class IotModule(ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be executed during the regular update cycle. """ - def __init__(self, device: "SmartDevice", module: str): - self._device: "SmartDevice" = device + def __init__(self, device: "IotDevice", module: str): + self._device = device self._module = module @abstractmethod diff --git a/kasa/modules/motion.py b/kasa/iot/modules/motion.py similarity index 95% rename from kasa/modules/motion.py rename to kasa/iot/modules/motion.py index 71d1a617..cd79cba7 100644 --- a/kasa/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -2,8 +2,8 @@ from enum import Enum from typing import Optional -from ..exceptions import SmartDeviceException -from .module import Module +from ...exceptions import SmartDeviceException +from .module import IotModule class Range(Enum): @@ -20,7 +20,7 @@ class Range(Enum): # "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} -class Motion(Module): +class Motion(IotModule): """Implements the motion detection (PIR) module.""" def query(self): diff --git a/kasa/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py similarity index 96% rename from kasa/modules/rulemodule.py rename to kasa/iot/modules/rulemodule.py index 05ef500f..f840f672 100644 --- a/kasa/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ except ImportError: from pydantic import BaseModel -from .module import Module, merge +from .module import IotModule, merge class Action(Enum): @@ -55,7 +55,7 @@ class Rule(BaseModel): _LOGGER = logging.getLogger(__name__) -class RuleModule(Module): +class RuleModule(IotModule): """Base class for rule-based modules, such as countdown and antitheft.""" def query(self): 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 92% rename from kasa/modules/time.py rename to kasa/iot/modules/time.py index d72e2d60..2099e22c 100644 --- a/kasa/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,11 +1,11 @@ """Provides the current time and timezone information.""" from datetime import datetime -from ..exceptions import SmartDeviceException -from .module import Module, merge +from ...exceptions import SmartDeviceException +from .module import IotModule, merge -class Time(Module): +class Time(IotModule): """Implements the timezone settings.""" def query(self): diff --git a/kasa/modules/usage.py b/kasa/iot/modules/usage.py similarity index 98% rename from kasa/modules/usage.py rename to kasa/iot/modules/usage.py index 10b9689d..29dcd172 100644 --- a/kasa/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 IotModule, merge -class Usage(Module): +class Usage(IotModule): """Baseclass for emeter/usage interfaces.""" def query(self): diff --git a/kasa/plug.py b/kasa/plug.py new file mode 100644 index 00000000..1271515e --- /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 new file mode 100644 index 00000000..c075ba32 --- /dev/null +++ b/kasa/smart/__init__.py @@ -0,0 +1,7 @@ +"""Package for supporting tapo-branded and newer kasa devices.""" +from .smartbulb import SmartBulb +from .smartchilddevice import SmartChildDevice +from .smartdevice import SmartDevice +from .smartplug import SmartPlug + +__all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"] diff --git a/kasa/tapo/tapobulb.py b/kasa/smart/smartbulb.py similarity index 92% rename from kasa/tapo/tapobulb.py rename to kasa/smart/smartbulb.py index cfd5768f..3ce4c6eb 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/smart/smartbulb.py @@ -1,9 +1,13 @@ """Module for tapo-branded smart bulbs (L5**).""" from typing import Any, Dict, List, Optional +from ..bulb import Bulb +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException -from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset -from .tapodevice import TapoDevice +from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from ..smartprotocol import SmartProtocol +from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { "L1": "Party", @@ -11,12 +15,22 @@ AVAILABLE_EFFECTS = { } -class TapoBulb(TapoDevice, SmartBulb): +class SmartBulb(SmartDevice, Bulb): """Representation of a TP-Link Tapo Bulb. - Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. + Documentation TBD. See :class:`~kasa.iot.Bulb` for now. """ + def __init__( + self, + host: str, + *, + config: Optional[DeviceConfig] = None, + protocol: Optional[SmartProtocol] = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self._device_type = DeviceType.Bulb + @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" @@ -257,6 +271,6 @@ class TapoBulb(TapoDevice, SmartBulb): return info @property - def presets(self) -> List[SmartBulbPreset]: + def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/tapo/childdevice.py b/kasa/smart/smartchilddevice.py similarity index 93% rename from kasa/tapo/childdevice.py rename to kasa/smart/smartchilddevice.py index 43b74851..69648d5e 100644 --- a/kasa/tapo/childdevice.py +++ b/kasa/smart/smartchilddevice.py @@ -4,10 +4,10 @@ from typing import Optional from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .tapodevice import TapoDevice +from .smartdevice import SmartDevice -class ChildDevice(TapoDevice): +class SmartChildDevice(SmartDevice): """Presentation of a child device. This wraps the protocol communications and sets internal data for the child. @@ -15,7 +15,7 @@ class ChildDevice(TapoDevice): def __init__( self, - parent: TapoDevice, + parent: SmartDevice, child_id: str, config: Optional[DeviceConfig] = None, protocol: Optional[SmartProtocol] = None, diff --git a/kasa/tapo/tapodevice.py b/kasa/smart/smartdevice.py similarity index 89% rename from kasa/tapo/tapodevice.py rename to kasa/smart/smartdevice.py index 0ef28d07..ca9ed63b 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/smart/smartdevice.py @@ -1,26 +1,25 @@ -"""Module for a TAPO device.""" +"""Module for a SMART device.""" import base64 import logging from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast from ..aestransport import AesTransport +from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException -from ..modules import Emeter -from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from .childdevice import ChildDevice + from .smartchilddevice import SmartChildDevice -class TapoDevice(SmartDevice): - """Base class to represent a TAPO device.""" +class SmartDevice(Device): + """Base class to represent a SMART protocol based device.""" def __init__( self, @@ -36,39 +35,31 @@ class TapoDevice(SmartDevice): self.protocol: SmartProtocol self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] = {} - self._children: Dict[str, "ChildDevice"] = {} + self._children: Dict[str, "SmartChildDevice"] = {} self._energy: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {} + self._time: Dict[str, Any] = {} async def _initialize_children(self): """Initialize children for power strips.""" children = self._last_update["child_info"]["child_device_list"] # TODO: Use the type information to construct children, # as hubs can also have them. - from .childdevice import ChildDevice + from .smartchilddevice import SmartChildDevice self._children = { - child["device_id"]: ChildDevice(parent=self, child_id=child["device_id"]) + child["device_id"]: SmartChildDevice( + parent=self, child_id=child["device_id"] + ) for child in children } self._device_type = DeviceType.Strip @property - def children(self): - """Return list of children. - - This is just to keep the existing SmartDevice API intact. - """ + def children(self) -> Sequence["SmartDevice"]: + """Return list of children.""" return list(self._children.values()) - @children.setter - def children(self, children): - """Initialize from a list of children. - - This is just to keep the existing SmartDevice API intact. - """ - self._children = {child["device_id"]: child for child in children} - async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -133,7 +124,6 @@ class TapoDevice(SmartDevice): """Initialize modules based on component negotiation response.""" if "energy_monitoring" in self._components: self.emeter_type = "emeter" - self.modules["emeter"] = Emeter(self, self.emeter_type) @property def sys_info(self) -> Dict[str, Any]: @@ -218,9 +208,9 @@ class TapoDevice(SmartDevice): return self._last_update async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, method: str, params: Optional[Dict] = None, child_ids=None ) -> Any: - res = await self.protocol.query({cmd: arg}) + res = await self.protocol.query({method: params}) return res @@ -276,6 +266,13 @@ class TapoDevice(SmartDevice): """Return adjusted emeter information.""" return data if not data else data * scale + def _verify_emeter(self) -> None: + """Raise an exception if there is no emeter.""" + if not self.has_emeter: + raise SmartDeviceException("Device has no emeter") + if self.emeter_type not in self._last_update: + raise SmartDeviceException("update() required prior accessing emeter") + @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" @@ -298,6 +295,17 @@ class TapoDevice(SmartDevice): """Get the emeter value for today.""" return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + @property + def on_since(self) -> Optional[datetime]: + """Return the time that the device was turned on or None if turned off.""" + if ( + not self._info.get("device_on") + or (on_time := self._info.get("on_time")) is None + ): + return None + on_time = cast(float, on_time) + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + async def wifi_scan(self) -> List[WifiNetwork]: """Scan for available wifi networks.""" diff --git a/kasa/tapo/tapoplug.py b/kasa/smart/smartplug.py similarity index 62% rename from kasa/tapo/tapoplug.py rename to kasa/smart/smartplug.py index e4355e4b..bd96b421 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/smart/smartplug.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 ..smartdevice import DeviceType +from ..plug import Plug from ..smartprotocol import SmartProtocol -from .tapodevice import TapoDevice +from .smartdevice import SmartDevice _LOGGER = logging.getLogger(__name__) -class TapoPlug(TapoDevice): +class SmartPlug(SmartDevice, Plug): """Class to represent a TAPO Plug.""" def __init__( @@ -35,11 +35,3 @@ class TapoPlug(TapoDevice): "auto_off_remain_time": self._info.get("auto_off_remain_time"), }, } - - @property - def on_since(self) -> Optional[datetime]: - """Return the time that the device was turned on or None if turned off.""" - if not self._info.get("device_on"): - return None - on_time = cast(float, self._info.get("on_time")) - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py deleted file mode 100644 index 0fe4297e..00000000 --- a/kasa/tapo/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Package for supporting tapo-branded and newer kasa devices.""" -from .childdevice import ChildDevice -from .tapobulb import TapoBulb -from .tapodevice import TapoDevice -from .tapoplug import TapoPlug - -__all__ = ["TapoDevice", "TapoPlug", "TapoBulb", "ChildDevice"] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 6ce491d1..b6e9135c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -13,18 +13,14 @@ import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/ from kasa import ( Credentials, + Device, DeviceConfig, Discover, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, SmartProtocol, - SmartStrip, ) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.protocol import BaseTransport -from kasa.tapo import TapoBulb, TapoPlug +from kasa.smart import SmartBulb, SmartPlug from kasa.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol @@ -350,37 +346,37 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return TapoPlug + return SmartPlug for d in BULBS_SMART: if d in model: - return TapoBulb + return SmartBulb for d in DIMMERS_SMART: if d in model: - return TapoBulb + return SmartBulb for d in STRIPS_SMART: if d in model: - return TapoPlug + return SmartPlug else: for d in STRIPS_IOT: if d in model: - return SmartStrip + return IotStrip for d in PLUGS_IOT: if d in model: - return SmartPlug + return IotPlug # Light strips are recognized also as bulbs, so this has to go first for d in BULBS_IOT_LIGHT_STRIP: if d in model: - return SmartLightStrip + return IotLightStrip for d in BULBS_IOT: if d in model: - return SmartBulb + return IotBulb for d in DIMMERS_IOT: if d in model: - return SmartDimmer + return IotDimmer raise Exception("Unable to find type for %s", model) @@ -446,11 +442,11 @@ async def dev(request): IP_MODEL_CACHE[ip] = model = d.model if model not in file: pytest.skip(f"skipping file {file}") - dev: SmartDevice = ( + dev: Device = ( d if d else await _discover_update_and_close(ip, username, password) ) else: - dev: SmartDevice = await get_device_for_file(file, protocol) + dev: Device = await get_device_for_file(file, protocol) yield dev diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index a92678b7..5cfb9e5e 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,7 +7,8 @@ from voluptuous import ( Schema, ) -from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException +from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException +from kasa.iot import IotBulb from .conftest import ( bulb, @@ -27,7 +28,7 @@ from .test_smartdevice import SYSINFO_SCHEMA @bulb -async def test_bulb_sysinfo(dev: SmartBulb): +async def test_bulb_sysinfo(dev: Bulb): assert dev.sys_info is not None SYSINFO_SCHEMA_BULB(dev.sys_info) @@ -40,7 +41,7 @@ async def test_bulb_sysinfo(dev: SmartBulb): @bulb -async def test_state_attributes(dev: SmartBulb): +async def test_state_attributes(dev: Bulb): assert "Brightness" in dev.state_information assert dev.state_information["Brightness"] == dev.brightness @@ -49,7 +50,7 @@ async def test_state_attributes(dev: SmartBulb): @bulb_iot -async def test_light_state_without_update(dev: SmartBulb, monkeypatch): +async def test_light_state_without_update(dev: IotBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None @@ -58,13 +59,13 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch): @bulb_iot -async def test_get_light_state(dev: SmartBulb): +async def test_get_light_state(dev: IotBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @color_bulb @turn_on -async def test_hsv(dev: SmartBulb, turn_on): +async def test_hsv(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -83,8 +84,8 @@ async def test_hsv(dev: SmartBulb, turn_on): @color_bulb_iot -async def test_set_hsv_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_set_hsv_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) set_light_state.assert_called_with( @@ -95,31 +96,31 @@ async def test_set_hsv_transition(dev: SmartBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: SmartBulb, turn_on): +async def test_invalid_hsv(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) + await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] for invalid_saturation in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) + await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) + await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb -async def test_color_state_information(dev: SmartBulb): +async def test_color_state_information(dev: Bulb): assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: SmartBulb): +async def test_hsv_on_non_color(dev: Bulb): assert not dev.is_color with pytest.raises(SmartDeviceException): @@ -129,7 +130,7 @@ async def test_hsv_on_non_color(dev: SmartBulb): @variable_temp -async def test_variable_temp_state_information(dev: SmartBulb): +async def test_variable_temp_state_information(dev: Bulb): assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @@ -141,7 +142,7 @@ async def test_variable_temp_state_information(dev: SmartBulb): @variable_temp @turn_on -async def test_try_set_colortemp(dev: SmartBulb, turn_on): +async def test_try_set_colortemp(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -149,15 +150,15 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on): @variable_temp_iot -async def test_set_color_temp_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_set_color_temp_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_color_temp(2700, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100) @variable_temp_iot -async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): +async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") assert dev.valid_temperature_range == (2700, 5000) @@ -165,7 +166,7 @@ async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): @variable_temp -async def test_out_of_range_temperature(dev: SmartBulb): +async def test_out_of_range_temperature(dev: Bulb): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -173,7 +174,7 @@ async def test_out_of_range_temperature(dev: SmartBulb): @non_variable_temp -async def test_non_variable_temp(dev: SmartBulb): +async def test_non_variable_temp(dev: Bulb): with pytest.raises(SmartDeviceException): await dev.set_color_temp(2700) @@ -186,7 +187,7 @@ async def test_non_variable_temp(dev: SmartBulb): @dimmable @turn_on -async def test_dimmable_brightness(dev: SmartBulb, turn_on): +async def test_dimmable_brightness(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -199,12 +200,12 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on): assert dev.brightness == 10 with pytest.raises(ValueError): - await dev.set_brightness("foo") + await dev.set_brightness("foo") # type: ignore[arg-type] @bulb_iot -async def test_turn_on_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_turn_on_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.turn_on(transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000) @@ -215,15 +216,15 @@ async def test_turn_on_transition(dev: SmartBulb, mocker): @bulb_iot -async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_dimmable_brightness_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_brightness(10, transition=1000) set_light_state.assert_called_with({"brightness": 10}, transition=1000) @dimmable -async def test_invalid_brightness(dev: SmartBulb): +async def test_invalid_brightness(dev: Bulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -234,7 +235,7 @@ async def test_invalid_brightness(dev: SmartBulb): @non_dimmable -async def test_non_dimmable(dev: SmartBulb): +async def test_non_dimmable(dev: Bulb): assert not dev.is_dimmable with pytest.raises(SmartDeviceException): @@ -245,9 +246,9 @@ async def test_non_dimmable(dev: SmartBulb): @bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: SmartBulb, mocker + dev: IotBulb, mocker ): - query_helper = mocker.patch("kasa.SmartBulb._query_helper") + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") # When turning back without settings, ignore default to restore the state await dev.turn_on() args, kwargs = query_helper.call_args_list[0] @@ -259,7 +260,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( @bulb_iot -async def test_list_presets(dev: SmartBulb): +async def test_list_presets(dev: IotBulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -272,7 +273,7 @@ async def test_list_presets(dev: SmartBulb): @bulb_iot -async def test_modify_preset(dev: SmartBulb, mocker): +async def test_modify_preset(dev: IotBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: pytest.skip("Some strips do not support presets") @@ -284,7 +285,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): "saturation": 0, "color_temp": 0, } - preset = SmartBulbPreset(**data) + preset = BulbPreset(**data) assert preset.index == 0 assert preset.brightness == 10 @@ -297,7 +298,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): with pytest.raises(SmartDeviceException): await dev.save_preset( - SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) @@ -306,21 +307,21 @@ async def test_modify_preset(dev: SmartBulb, mocker): ("preset", "payload"), [ ( - SmartBulbPreset(index=0, hue=0, brightness=1, saturation=0), + BulbPreset(index=0, hue=0, brightness=1, saturation=0), {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - SmartBulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], ) -async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): +async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): """Test that modify preset payloads ignore none values.""" if not dev.presets: pytest.skip("Some strips do not support presets") - query_helper = mocker.patch("kasa.SmartBulb._query_helper") + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") await dev.save_preset(preset) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 077a1f2d..3247c917 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,8 +3,8 @@ import sys import pytest +from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smartprotocol import _ChildProtocolWrapper -from kasa.tapo.childdevice import ChildDevice from .conftest import strip_smart @@ -42,7 +42,7 @@ async def test_childdevice_update(dev, dummy_protocol, mocker): sys.version_info < (3, 11), reason="exceptiongroup requires python3.11+", ) -async def test_childdevice_properties(dev: ChildDevice): +async def test_childdevice_properties(dev: SmartChildDevice): """Check that accessing childdevice properties do not raise exceptions.""" assert len(dev.children) > 0 diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index df1f6456..58370d74 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,8 +7,8 @@ from asyncclick.testing import CliRunner from kasa import ( AuthenticationException, + Device, EmeterStatus, - SmartDevice, SmartDeviceException, UnsupportedDeviceException, ) @@ -27,6 +27,7 @@ from kasa.cli import ( wifi, ) from kasa.discover import Discover, DiscoveryResult +from kasa.iot import IotDevice from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on @@ -107,9 +108,9 @@ async def test_alias(dev): async def test_raw_command(dev, mocker): runner = CliRunner() update = mocker.patch.object(dev, "update") - from kasa.tapo import TapoDevice + from kasa.smart import SmartDevice - if isinstance(dev, TapoDevice): + if isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: params = ["system", "get_sysinfo"] @@ -216,7 +217,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: SmartDevice, mocker): +async def test_emeter(dev: Device, mocker): runner = CliRunner() res = await runner.invoke(emeter, obj=dev) @@ -245,16 +246,24 @@ async def test_emeter(dev: SmartDevice, mocker): assert "Voltage: 122.066 V" in res.output assert realtime_emeter.call_count == 2 - monthly = mocker.patch.object(dev, "get_emeter_monthly") - monthly.return_value = {1: 1234} + if isinstance(dev, IotDevice): + monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device has no historical statistics" in res.output + return assert "For year" in res.output assert "1, 1234" in res.output monthly.assert_called_with(year=1900) - daily = mocker.patch.object(dev, "get_emeter_daily") - daily.return_value = {1: 1234} + if isinstance(dev, IotDevice): + daily = mocker.patch.object(dev, "get_emeter_daily") + daily.return_value = {1: 1234} res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device has no historical statistics" in res.output + return assert "For month" in res.output assert "1, 1234" in res.output daily.assert_called_with(year=1900, month=12) @@ -279,7 +288,7 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: SmartDevice, mocker): +async def test_json_output(dev: Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) runner = CliRunner() @@ -292,10 +301,10 @@ async def test_json_output(dev: SmartDevice, mocker): async def test_credentials(discovery_mock, mocker): """Test credentials are passed correctly from cli to device.""" # Patch state to echo username and password - pass_dev = click.make_pass_decorator(SmartDevice) + pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: SmartDevice): + async def _state(dev: Device): if dev.credentials: click.echo( f"Username:{dev.credentials.username} Password:{dev.credentials.password}" @@ -513,10 +522,10 @@ async def test_type_param(device_type, mocker): runner = CliRunner() result_device = FileNotFoundError - pass_dev = click.make_pass_decorator(SmartDevice) + pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: SmartDevice): + async def _state(dev: Device): nonlocal result_device result_device = dev diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index f0f73cf2..67ab39d5 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -6,8 +6,8 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( Credentials, + Device, Discover, - SmartDevice, SmartDeviceException, ) from kasa.device_factory import connect, get_protocol @@ -83,7 +83,7 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) dev = await connect(config=config) - assert issubclass(dev.__class__, SmartDevice) + assert issubclass(dev.__class__, Device) assert dev.port == custom_port or dev.port == default_port diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py index da1707dc..099f0862 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.device_type import DeviceType async def test_device_type_from_value(): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index b5e98b78..fafa9544 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.iot import IotDimmer from .conftest import dimmer, handle_turn_on, turn_on @@ -23,7 +23,7 @@ async def test_set_brightness(dev, turn_on): @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_brightness(99, transition=1000) @@ -53,7 +53,7 @@ async def test_set_brightness_invalid(dev): @dimmer async def test_turn_on_transition(dev, mocker): - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness await dev.turn_on(transition=1000) @@ -71,7 +71,7 @@ async def test_turn_on_transition(dev, mocker): @dimmer async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness await dev.turn_off(transition=1000) @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(99, 1000) @@ -109,7 +109,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) original_brightness = dev.brightness - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index f2344801..e0a7fdd4 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -10,9 +10,9 @@ from async_timeout import timeout as asyncio_timeout from kasa import ( Credentials, + Device, DeviceType, Discover, - SmartDevice, SmartDeviceException, ) from kasa.deviceconfig import ( @@ -21,6 +21,7 @@ from kasa.deviceconfig import ( ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException +from kasa.iot import IotDevice from kasa.xortransport import XorEncryption from .conftest import ( @@ -55,14 +56,14 @@ UNSUPPORTED = { @plug -async def test_type_detection_plug(dev: SmartDevice): +async def test_type_detection_plug(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug assert d.device_type == DeviceType.Plug @bulb_iot -async def test_type_detection_bulb(dev: SmartDevice): +async def test_type_detection_bulb(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it if not d.is_light_strip: @@ -71,21 +72,21 @@ async def test_type_detection_bulb(dev: SmartDevice): @strip_iot -async def test_type_detection_strip(dev: SmartDevice): +async def test_type_detection_strip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_strip assert d.device_type == DeviceType.Strip @dimmer -async def test_type_detection_dimmer(dev: SmartDevice): +async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer @lightstrip -async def test_type_detection_lightstrip(dev: SmartDevice): +async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip assert d.device_type == DeviceType.LightStrip @@ -111,7 +112,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): x = await Discover.discover_single( host, port=custom_port, credentials=Credentials() ) - assert issubclass(x.__class__, SmartDevice) + assert issubclass(x.__class__, Device) assert x._discovery_info is not None assert x.port == custom_port or x.port == discovery_mock.default_port assert update_mock.call_count == 0 @@ -144,7 +145,7 @@ async def test_discover_single_hostname(discovery_mock, mocker): update_mock = mocker.patch.object(device_class, "update") x = await Discover.discover_single(host, credentials=Credentials()) - assert issubclass(x.__class__, SmartDevice) + assert issubclass(x.__class__, Device) assert x._discovery_info is not None assert x.host == host assert update_mock.call_count == 0 @@ -232,7 +233,7 @@ async def test_discover_datagram_received(mocker, discovery_data): # Check that unsupported device is 1 assert len(proto.unsupported_device_exceptions) == 1 dev = proto.discovered_devices[addr] - assert issubclass(dev.__class__, SmartDevice) + assert issubclass(dev.__class__, Device) assert dev.host == addr @@ -298,7 +299,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery async def test_device_update_from_new_discovery_info(discovery_data): - device = SmartDevice("127.0.0.7") + device = IotDevice("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" @@ -323,7 +324,7 @@ async def test_discover_single_http_client(discovery_mock, mocker): http_client = aiohttp.ClientSession() - x: SmartDevice = await Discover.discover_single(host) + x: Device = await Discover.discover_single(host) assert x.config.uses_http == (discovery_mock.default_port == 80) @@ -341,7 +342,7 @@ async def test_discover_http_client(discovery_mock, mocker): http_client = aiohttp.ClientSession() devices = await Discover.discover(discovery_timeout=0) - x: SmartDevice = devices[host] + x: Device = devices[host] assert x.config.uses_http == (discovery_mock.default_port == 80) if discovery_mock.default_port == 80: diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index dbd75024..809764fa 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -11,7 +11,8 @@ from voluptuous import ( ) from kasa import EmeterStatus, SmartDeviceException -from kasa.modules.emeter import Emeter +from kasa.iot import IotDevice +from kasa.iot.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -39,12 +40,15 @@ async def test_no_emeter(dev): with pytest.raises(SmartDeviceException): await dev.get_emeter_realtime() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_daily() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_monthly() - with pytest.raises(SmartDeviceException): - await dev.erase_emeter_stats() + # Only iot devices support the historical stats so other + # devices will not implement the methods below + if isinstance(dev, IotDevice): + with pytest.raises(SmartDeviceException): + await dev.get_emeter_daily() + with pytest.raises(SmartDeviceException): + await dev.get_emeter_monthly() + with pytest.raises(SmartDeviceException): + await dev.erase_emeter_stats() @has_emeter @@ -121,7 +125,7 @@ async def test_erase_emeter_stats(dev): await dev.erase_emeter() -@has_emeter +@has_emeter_iot async def test_current_consumption(dev): if dev.has_emeter: x = await dev.current_consumption() diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 109b9d7c..9ded007a 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,27 +1,28 @@ import pytest -from kasa import DeviceType, SmartLightStrip +from kasa import DeviceType from kasa.exceptions import SmartDeviceException +from kasa.iot import IotLightStrip from .conftest import lightstrip @lightstrip -async def test_lightstrip_length(dev: SmartLightStrip): +async def test_lightstrip_length(dev: IotLightStrip): assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] @lightstrip -async def test_lightstrip_effect(dev: SmartLightStrip): +async def test_lightstrip_effect(dev: IotLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect @lightstrip -async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): +async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(SmartDeviceException): await dev.set_effect("Not real") @@ -33,9 +34,9 @@ async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): @lightstrip @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( - dev: SmartLightStrip, brightness, mocker + dev: IotLightStrip, brightness, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default brightness works (100 for candy cane) if brightness == 100: @@ -51,9 +52,9 @@ async def test_effects_lightstrip_set_effect_brightness( @lightstrip @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( - dev: SmartLightStrip, transition, mocker + dev: IotLightStrip, transition, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default (500 for candy cane) transition works if transition == 500: @@ -67,6 +68,6 @@ async def test_effects_lightstrip_set_effect_transition( @lightstrip -async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): +async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 416cbec8..ec2099c6 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -8,54 +8,54 @@ from kasa.tests.conftest import get_device_for_file def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) - mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) - mocker.patch("kasa.smartbulb.SmartBulb.update") - res = xdoctest.doctest_module("kasa.smartbulb", "all") + mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p) + mocker.patch("kasa.iot.iotbulb.IotBulb.update") + res = xdoctest.doctest_module("kasa.iot.iotbulb", "all") assert not res["failed"] def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) - mocker.patch("kasa.smartdevice.SmartDevice.update") - res = xdoctest.doctest_module("kasa.smartdevice", "all") + mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p) + mocker.patch("kasa.iot.iotdevice.IotDevice.update") + res = xdoctest.doctest_module("kasa.iot.iotdevice", "all") assert not res["failed"] def test_plug_examples(mocker): """Test plug examples.""" p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - mocker.patch("kasa.smartplug.SmartPlug", return_value=p) - mocker.patch("kasa.smartplug.SmartPlug.update") - res = xdoctest.doctest_module("kasa.smartplug", "all") + mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p) + mocker.patch("kasa.iot.iotplug.IotPlug.update") + res = xdoctest.doctest_module("kasa.iot.iotplug", "all") assert not res["failed"] def test_strip_examples(mocker): """Test strip examples.""" p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) - mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) - mocker.patch("kasa.smartstrip.SmartStrip.update") - res = xdoctest.doctest_module("kasa.smartstrip", "all") + mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p) + mocker.patch("kasa.iot.iotstrip.IotStrip.update") + res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") assert not res["failed"] def test_dimmer_examples(mocker): """Test dimmer examples.""" p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) - mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) - mocker.patch("kasa.smartdimmer.SmartDimmer.update") - res = xdoctest.doctest_module("kasa.smartdimmer", "all") + mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p) + mocker.patch("kasa.iot.iotdimmer.IotDimmer.update") + res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all") assert not res["failed"] def test_lightstrip_examples(mocker): """Test lightstrip examples.""" p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) - mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) - mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") - res = xdoctest.doctest_module("kasa.smartlightstrip", "all") + mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p) + mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update") + res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all") assert not res["failed"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c4681ee8..ba5ebc4f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,5 +1,8 @@ +import importlib import inspect +import pkgutil import re +import sys from datetime import datetime from unittest.mock import Mock, patch @@ -17,20 +20,33 @@ from voluptuous import ( ) import kasa -from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException +from kasa import Credentials, Device, DeviceConfig, SmartDeviceException +from kasa.iot import IotDevice +from kasa.smart import SmartChildDevice, SmartDevice from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .fakeprotocol_iot import FakeIotProtocol -# List of all SmartXXX classes including the SmartDevice base class -smart_device_classes = [ - dc - for (mn, dc) in inspect.getmembers( - kasa, - lambda member: inspect.isclass(member) - and (member == SmartDevice or issubclass(member, SmartDevice)), - ) -] + +def _get_subclasses(of_class): + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and module.__package__ != "kasa" + ): + subclasses.add((module.__package__ + "." + name, obj)) + return subclasses + + +device_classes = pytest.mark.parametrize( + "device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0] +) @device_iot @@ -220,21 +236,26 @@ async def test_estimated_response_sizes(dev): assert mod.estimated_query_response_size > 0 -@pytest.mark.parametrize("device_class", smart_device_classes) -def test_device_class_ctors(device_class): +@device_classes +async def test_device_class_ctors(device_class_name_obj): """Make sure constructor api not broken for new and existing SmartDevices.""" host = "127.0.0.2" port = 1234 credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) - dev = device_class(host, config=config) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) + dev = klass(parent, 1) + else: + dev = klass(host, config=config) assert dev.host == host assert dev.port == port assert dev.credentials == credentials @device_iot -async def test_modules_preserved(dev: SmartDevice): +async def test_modules_preserved(dev: IotDevice): """Make modules that are not being updated are preserved between updates.""" dev._last_update["some_module_not_being_updated"] = "should_be_kept" await dev.update() @@ -244,6 +265,8 @@ async def test_modules_preserved(dev: SmartDevice): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" + dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -258,7 +281,7 @@ async def test_create_thin_wrapper(): credentials=Credentials("username", "password"), ) with patch("kasa.device_factory.connect", return_value=mock) as connect: - dev = await SmartDevice.connect(config=config) + dev = await Device.connect(config=config) assert dev is mock connect.assert_called_once_with( @@ -268,7 +291,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: SmartDevice): +async def test_modules_not_supported(dev: IotDevice): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None @@ -277,6 +300,21 @@ async def test_modules_not_supported(dev: SmartDevice): assert module.is_supported is not None +@pytest.mark.parametrize( + "device_class, use_class", kasa.deprecated_smart_devices.items() +) +def test_deprecated_devices(device_class, use_class): + package_name = ".".join(use_class.__module__.split(".")[:-1]) + msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, device_class) + packages = package_name.split(".") + module = __import__(packages[0]) + for _ in packages[1:]: + module = importlib.import_module(package_name, package=module.__name__) + getattr(module, use_class.__name__) + + def check_mac(x): if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): return x diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 451b7e34..623adde6 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,7 +2,8 @@ from datetime import datetime import pytest -from kasa import SmartDeviceException, SmartStrip +from kasa import SmartDeviceException +from kasa.iot import IotStrip from .conftest import handle_turn_on, strip, turn_on @@ -68,7 +69,7 @@ async def test_children_on_since(dev): @strip -async def test_get_plug_by_name(dev: SmartStrip): +async def test_get_plug_by_name(dev: IotStrip): name = dev.children[0].alias assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] @@ -77,7 +78,7 @@ async def test_get_plug_by_name(dev: SmartStrip): @strip -async def test_get_plug_by_index(dev: SmartStrip): +async def test_get_plug_by_index(dev: IotStrip): assert dev.get_plug_by_index(0) == dev.children[0] with pytest.raises(SmartDeviceException): diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 9f42fca1..3f6c5056 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -1,7 +1,7 @@ import datetime from unittest.mock import Mock -from kasa.modules import Usage +from kasa.iot.modules import Usage def test_usage_convert_stat_data():