diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c0438d9..c274bb97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,11 @@ repos: hooks: - id: mypy additional_dependencies: [types-click] + exclude: | + (?x)^( + kasa/modulemapping\.py| + )$ + - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1f400543 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing to python-kasa + +All types of contributions are very welcome. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-miio.readthedocs.io/en/latest/contribute.html) to get you started. diff --git a/README.md b/README.md index 85fc6982..6c4cfcce 100644 --- a/README.md +++ b/README.md @@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages: ## Contributing -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. - -### Setting up development environment - -To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. - -### Code-style checks - -We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly -before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. -This will make sure that the checks are passing when you do a commit. - -You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. - -### Running tests - -You can run tests on the library by executing `pytest` in the source directory. -This will run the tests against contributed example responses, but you can also execute the tests against a real device: -``` -$ pytest --ip
-``` -Note that this will perform state changes on the device. - -### Analyzing network captures - -The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -or the `parse_pcap.py` script contained inside the `devtools` directory. -Note, that this works currently only on kasa-branded devices which use port 9999 for communications. - +Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported. +Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html). ## Supported devices -The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one). +The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). @@ -242,7 +212,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index e5269763..f3c505e4 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -137,6 +137,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - Hardware: 1.0 (EU) / Firmware: 2.8.0\* - Hardware: 1.0 (UK) / Firmware: 2.8.0\* @@ -208,10 +209,13 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **H100** - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 ### Hub-Connected Devices +- **T110** + - Hardware: 1.0 (EU) / Firmware: 1.8.0 - **T300** - Hardware: 1.0 (EU) / Firmware: 1.7.0 - **T310** diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 8372bfff..ed881a88 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -19,7 +19,7 @@ app = typer.Typer() 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 + module_dir = outputdir / str(name) if not module_dir.exists(): module_dir.mkdir(exist_ok=True, parents=True) diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 00000000..67291eba --- /dev/null +++ b/docs/source/contribute.md @@ -0,0 +1,86 @@ +# Contributing + +You probably arrived to this page as you are interested in contributing to python-kasa in some form? +All types of contributions are very welcome, so thank you! +This page aims to help you to get started. + +```{contents} Contents + :local: +``` + +## Setting up the development environment + +To get started, simply clone this repository and initialize the development environment. +We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute +`poetry install` which will install all necessary packages and create a virtual environment for you. + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +``` + +## Code-style checks + +We use several tools to automatically check all contributions as part of our CI pipeline. +The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. + +```{note} +You can also execute the pre-commit hooks on all files by executing `pre-commit run -a` +``` + +## Running tests + +You can run tests on the library by executing `pytest` in the source directory: + +``` +$ poetry run pytest kasa +``` + +This will run the tests against the contributed example responses. + +```{note} +You can also execute the tests against a real device using `pytest --ip
`. +Note that this will perform state changes on the device. +``` + +## Analyzing network captures + +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. + +## Contributing fixture files + +One of the easiest ways to contribute is by creating a fixture file and uploading it for us. +These files will help us to improve the library and run tests against devices that we have no access to. + +This library is tested against responses from real devices ("fixture files"). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/kasa/tests/fixtures). + +You can generate these files by using the `dump_devinfo.py` script. +Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. +The easiest way to do that is by doing: + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +$ poetry shell +$ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 +``` + +```{note} +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target network 192.168.1.255` +``` + +The script will run queries against the device, and prompt at the end if you want to save the results. +If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. + +```{note} +When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `poetry run pytest kasa`. +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 9dc648a9..f5baf389 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,6 +10,7 @@ discover smartdevice design + contribute smartbulb smartplug smartdimmer diff --git a/kasa/__init__.py b/kasa/__init__.py index 394fa72e..8428154e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,6 @@ from importlib.metadata import version from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -36,13 +35,12 @@ from kasa.exceptions import ( UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.firmware import Firmware, FirmwareUpdate -from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors +from kasa.interfaces.light import Light, LightPreset from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) -from kasa.plug import Plug +from kasa.module import Module from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol @@ -54,15 +52,16 @@ __all__ = [ "BaseProtocol", "IotProtocol", "SmartProtocol", - "BulbPreset", + "LightPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "Feature", "EmeterStatus", "Device", - "Bulb", + "Light", "Plug", + "Module", "KasaException", "AuthenticationError", "DeviceError", @@ -73,8 +72,6 @@ __all__ = [ "ConnectionType", "EncryptType", "DeviceFamilyType", - "Firmware", - "FirmwareUpdate", ] from . import iot @@ -87,7 +84,7 @@ deprecated_smart_devices = { "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": BulbPreset, + "SmartBulbPreset": LightPreset, } deprecated_exceptions = { "SmartDeviceException": KasaException, @@ -127,7 +124,7 @@ if TYPE_CHECKING: SmartLightStrip = iot.IotLightStrip SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer - SmartBulbPreset = BulbPreset + SmartBulbPreset = LightPreset SmartDeviceException = KasaException UnsupportedDeviceException = UnsupportedDeviceError diff --git a/kasa/cli.py b/kasa/cli.py index 386d2e1c..5a60bf19 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,7 +18,6 @@ from pydantic.v1 import ValidationError from kasa import ( AuthenticationError, - Bulb, ConnectionType, Credentials, Device, @@ -28,6 +27,7 @@ from kasa import ( EncryptType, Feature, KasaException, + Module, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -859,18 +859,18 @@ async def usage(dev: Device, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: Bulb, brightness: int, transition: int): +async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: echo("This device does not support brightness.") return if brightness is None: - echo(f"Brightness: {dev.brightness}") - return dev.brightness + echo(f"Brightness: {light.brightness}") + return light.brightness else: echo(f"Setting brightness to {brightness}") - return await dev.set_brightness(brightness, transition=transition) + return await light.set_brightness(brightness, transition=transition) @cli.command() @@ -879,15 +879,15 @@ async def brightness(dev: Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Bulb, temperature: int, transition: int): +async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" - if not dev.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: echo("Device does not support color temperature") return if temperature is None: - echo(f"Color temperature: {dev.color_temp}") - valid_temperature_range = dev.valid_temperature_range + echo(f"Color temperature: {light.color_temp}") + valid_temperature_range = light.valid_temperature_range if valid_temperature_range != (0, 0): echo("(min: {}, max: {})".format(*valid_temperature_range)) else: @@ -895,31 +895,34 @@ async def temperature(dev: Bulb, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) - return dev.valid_temperature_range + return light.valid_temperature_range else: echo(f"Setting color temperature to {temperature}") - return await dev.set_color_temp(temperature, transition=transition) + return await light.set_color_temp(temperature, transition=transition) @cli.command() @click.argument("effect", type=click.STRING, default=None, required=False) @click.pass_context @pass_dev -async def effect(dev, ctx, effect): +async def effect(dev: Device, ctx, effect): """Set an effect.""" - if not dev.has_effects: + if not (light_effect := dev.modules.get(Module.LightEffect)): echo("Device does not support effects") return if effect is None: raise click.BadArgumentUsage( - f"Setting an effect requires a named built-in effect: {dev.effect_list}", + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}", ctx, ) - if effect not in dev.effect_list: - raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) echo(f"Setting Effect: {effect}") - return await dev.set_effect(effect) + return await light_effect.set_effect(effect) @cli.command() @@ -929,33 +932,36 @@ async def effect(dev, ctx, effect): @click.option("--transition", type=int, required=False) @click.pass_context @pass_dev -async def hsv(dev, ctx, h, s, v, transition): +async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" - if not dev.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.is_color: echo("Device does not support colors") return - if h is None or s is None or v is None: - echo(f"Current HSV: {dev.hsv}") - return dev.hsv + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: echo(f"Setting HSV: {h} {s} {v}") - return await dev.set_hsv(h, s, v, transition=transition) + return await light.set_hsv(h, s, v, transition=transition) @cli.command() @click.argument("state", type=bool, required=False) @pass_dev -async def led(dev, state): +async def led(dev: Device, state): """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + echo("Device does not support led.") + return if state is not None: echo(f"Turning led to {state}") - return await dev.set_led(state) + return await led.set_led(state) else: - echo(f"LED state: {dev.led}") - return dev.led + echo(f"LED state: {led.led}") + return led.led @cli.command() @@ -975,8 +981,8 @@ async def time(dev): 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!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -996,8 +1002,8 @@ async def on(dev: Device, index: int, name: str, transition: int): async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -1017,8 +1023,8 @@ async def off(dev: Device, index: int, name: str, transition: int): async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -1266,7 +1272,11 @@ async def firmware(ctx: click.Context, dev: Device): @click.pass_context async def firmware_info(ctx: click.Context, dev: Device): """Return firmware information.""" - res = await dev.firmware.check_for_updates() + if not (firmware := dev.modules.get(Module.Firmware)): + echo("This device does not support firmware info.") + return + + res = await firmware.check_for_updates() if res.update_available: echo("[green bold]Update available![/green bold]") echo(f"Current firmware: {res.current_version}") @@ -1291,7 +1301,7 @@ async def firmware_update(ctx: click.Context, dev: Device): echo(f"Progress: {x}") echo("Going to update %s", dev) - await dev.firmware.update_firmware(progress_cb=progress) # type: ignore + await dev.modules.get[Module.Firmware].update_firmware(progress_cb=progress) # type: ignore if __name__ == "__main__": diff --git a/kasa/device.py b/kasa/device.py index fc400a04..0f88f3a1 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,8 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence +from warnings import warn from .credentials import Credentials from .device_type import DeviceType @@ -14,12 +15,14 @@ from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature -from .firmware import Firmware from .iotprotocol import IotProtocol -from .module import Module, ModuleT +from .module import Module from .protocol import BaseProtocol from .xortransport import XorTransport +if TYPE_CHECKING: + from .modulemapping import ModuleMapping + @dataclass class WifiNetwork: @@ -114,21 +117,9 @@ class Device(ABC): @property @abstractmethod - def modules(self) -> Mapping[str, Module]: + def modules(self) -> ModuleMapping[Module]: """Return the device modules.""" - @overload - @abstractmethod - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - @abstractmethod - def get_module(self, module_type: str) -> Module | None: ... - - @abstractmethod - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - @property @abstractmethod def is_on(self) -> bool: @@ -218,61 +209,6 @@ class Device(ABC): 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_wallswitch(self) -> bool: - """Return True if the device is a switch.""" - return self.device_type == DeviceType.WallSwitch - - @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_fan(self) -> bool: - """Return True if the device is a fan.""" - return self.device_type == DeviceType.Fan - - @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: @@ -289,11 +225,6 @@ class Device(ABC): ) return self.children[index] - @property - @abstractmethod - def firmware(self) -> Firmware: - """Return firmware.""" - @property @abstractmethod def time(self) -> datetime: @@ -398,3 +329,53 @@ class Device(ABC): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" + + _deprecated_attributes = { + # is_type + "is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb), + "is_dimmer": ( + Module.Light, + lambda self: self.device_type == DeviceType.Dimmer, + ), + "is_light_strip": ( + Module.LightEffect, + lambda self: self.device_type == DeviceType.LightStrip, + ), + "is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug), + "is_wallswitch": ( + Module.Led, + lambda self: self.device_type == DeviceType.WallSwitch, + ), + "is_strip": (None, lambda self: self.device_type == DeviceType.Strip), + "is_strip_socket": ( + None, + lambda self: self.device_type == DeviceType.StripSocket, + ), # TODO + # is_light_function + "is_color": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_color, + ), + "is_dimmable": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_dimmable, + ), + "is_variable_color_temp": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_variable_color_temp, + ), + } + + def __getattr__(self, name) -> bool: + if name in self._deprecated_attributes: + module = self._deprecated_attributes[name][0] + func = self._deprecated_attributes[name][1] + msg = f"{name} is deprecated" + if module: + msg += f", use: {module} in device.modules instead" + warn(msg, DeprecationWarning, stacklevel=1) + return func(self) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py new file mode 100644 index 00000000..5328289b --- /dev/null +++ b/kasa/interfaces/__init__.py @@ -0,0 +1,16 @@ +"""Package for interfaces.""" + +from .fan import Fan +from .firmware import Firmware +from .led import Led +from .light import Light, LightPreset +from .lighteffect import LightEffect + +__all__ = [ + "Fan", + "Firmware", + "Led", + "Light", + "LightEffect", + "LightPreset", +] diff --git a/kasa/fan.py b/kasa/interfaces/fan.py similarity index 87% rename from kasa/fan.py rename to kasa/interfaces/fan.py index e881136e..89d8d82b 100644 --- a/kasa/fan.py +++ b/kasa/interfaces/fan.py @@ -4,10 +4,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from .device import Device +from ..module import Module -class Fan(Device, ABC): +class Fan(Module, ABC): """Interface for a Fan.""" @property diff --git a/kasa/firmware.py b/kasa/interfaces/firmware.py similarity index 61% rename from kasa/firmware.py rename to kasa/interfaces/firmware.py index 71592c64..705b1e35 100644 --- a/kasa/firmware.py +++ b/kasa/interfaces/firmware.py @@ -5,13 +5,25 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import date -from typing import Any, Awaitable, Callable +from typing import Callable, Coroutine + +from ..module import Module UpdateResult = bool +class FirmwareDownloadState(ABC): + """Download state.""" + + status: int + progress: int + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + @dataclass -class FirmwareUpdate: +class FirmwareUpdateInfo: """Update info status object.""" update_available: bool | None = None @@ -21,21 +33,19 @@ class FirmwareUpdate: release_notes: str | None = None -class Firmware(ABC): +class Firmware(Module, ABC): """Interface to access firmware information and perform updates.""" @abstractmethod async def update_firmware( - self, *, progress_cb: Callable[[Any, Any], Awaitable] + self, *, progress_cb: Callable[[FirmwareDownloadState], Coroutine] | None = None ) -> UpdateResult: """Perform firmware update. This "blocks" until the update process has finished. You can set *progress_cb* to get progress updates. """ - raise NotImplementedError @abstractmethod - async def check_for_updates(self) -> FirmwareUpdate: - """Return information about available updates.""" - raise NotImplementedError + async def check_for_updates(self) -> FirmwareUpdateInfo: + """Return firmware update information.""" diff --git a/kasa/interfaces/led.py b/kasa/interfaces/led.py new file mode 100644 index 00000000..2ddba00c --- /dev/null +++ b/kasa/interfaces/led.py @@ -0,0 +1,38 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class Led(Module, ABC): + """Base interface to represent a LED module.""" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + id="led", + icon="mdi:led", + attribute_getter="led", + attribute_setter="set_led", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + @abstractmethod + def led(self) -> bool: + """Return current led status.""" + + @abstractmethod + async def set_led(self, enable: bool) -> None: + """Set led.""" diff --git a/kasa/bulb.py b/kasa/interfaces/light.py similarity index 85% rename from kasa/bulb.py rename to kasa/interfaces/light.py index 01065dc0..3a8805c1 100644 --- a/kasa/bulb.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from typing import NamedTuple, Optional from pydantic.v1 import BaseModel -from .device import Device +from ..module import Module class ColorTempRange(NamedTuple): @@ -25,8 +25,8 @@ class HSV(NamedTuple): value: int -class BulbPreset(BaseModel): - """Bulb configuration preset.""" +class LightPreset(BaseModel): + """Light configuration preset.""" index: int brightness: int @@ -42,23 +42,19 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Device, ABC): - """Base class for TP-Link Bulb.""" +class Light(Module, ABC): + """Base class for TP-Link Light.""" - 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_dimmable(self) -> bool: + """Whether the light supports brightness changes.""" @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: @@ -137,8 +133,3 @@ class Bulb(Device, ABC): :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/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py new file mode 100644 index 00000000..0eb11b5b --- /dev/null +++ b/kasa/interfaces/lighteffect.py @@ -0,0 +1,80 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class LightEffect(Module, ABC): + """Interface to represent a light effect module.""" + + LIGHT_EFFECTS_OFF = "Off" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_effect", + name="Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Primary, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + @property + @abstractmethod + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + + @property + @abstractmethod + def effect(self) -> str: + """Return effect state or name.""" + + @property + @abstractmethod + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + + @abstractmethod + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ diff --git a/kasa/effects.py b/kasa/iot/effects.py similarity index 100% rename from kasa/effects.py rename to kasa/iot/effects.py diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 6819d94b..51df94d1 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -9,13 +9,22 @@ from typing import Optional, cast from pydantic.v1 import BaseModel, Field, root_validator -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature +from ..interfaces.light import HSV, ColorTempRange, LightPreset +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + Light, + Schedule, + Time, + Usage, +) class BehaviorMode(str, Enum): @@ -87,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Bulb): +class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -169,9 +178,9 @@ class IotBulb(IotDevice, Bulb): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [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)] + [LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] - To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` + To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] @@ -198,64 +207,40 @@ class IotBulb(IotDevice, Bulb): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb - self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) - self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) - self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) - self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) - self.add_module("emeter", Emeter(self, self.emeter_type)) - self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) - async def _initialize_features(self): - await super()._initialize_features() - - if bool(self.sys_info["is_dimmable"]): # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - - if self.is_variable_color_temp: - self._add_feature( - Feature( - device=self, - id="color_temperature", - name="Color temperature", - container=self, - attribute_getter="color_temp", - attribute_setter="set_color_temp", - range_getter="valid_temperature_range", - category=Feature.Category.Primary, - type=Feature.Type.Number, - ) - ) + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() + self.add_module( + Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") + ) + self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule")) + self.add_module( + Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") + ) + self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update - def is_color(self) -> bool: + def _is_color(self) -> bool: """Whether the bulb supports color changes.""" sys_info = self.sys_info return bool(sys_info["is_color"]) @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" sys_info = self.sys_info return bool(sys_info["is_dimmable"]) @property # type: ignore @requires_update - def is_variable_color_temp(self) -> bool: + def _is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" sys_info = self.sys_info return bool(sys_info["is_variable_color_temp"]) @@ -267,7 +252,7 @@ class IotBulb(IotDevice, Bulb): :return: White temperature range in Kelvin (minimum, maximum) """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Color temperature not supported") for model, temp_range in TPLINK_KELVIN.items(): @@ -367,7 +352,7 @@ class IotBulb(IotDevice, Bulb): :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") light_state = cast(dict, self.light_state) @@ -394,7 +379,7 @@ class IotBulb(IotDevice, Bulb): :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): @@ -421,7 +406,7 @@ class IotBulb(IotDevice, Bulb): @requires_update def color_temp(self) -> int: """Return color temperature of the device in kelvin.""" - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") light_state = self.light_state @@ -436,7 +421,7 @@ class IotBulb(IotDevice, Bulb): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range @@ -453,11 +438,15 @@ class IotBulb(IotDevice, Bulb): return await self.set_light_state(light_state, transition=transition) + 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 # type: ignore @requires_update def brightness(self) -> int: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") light_state = self.light_state @@ -472,7 +461,7 @@ class IotBulb(IotDevice, Bulb): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) @@ -518,11 +507,11 @@ class IotBulb(IotDevice, Bulb): @property # type: ignore @requires_update - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" - return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]] - async def save_preset(self, preset: BulbPreset): + async def save_preset(self, preset: LightPreset): """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 144d803d..f3ac5321 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,18 +19,18 @@ import functools import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature -from ..firmware import Firmware -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule -from .modules import Emeter, Time +from .modules import Emeter _LOGGER = logging.getLogger(__name__) @@ -191,7 +191,7 @@ class IotDevice(Device): self._supported_modules: dict[str, IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} - self._modules: dict[str, IotModule] = {} + self._modules: dict[str | ModuleName[Module], IotModule] = {} @property def children(self) -> Sequence[IotDevice]: @@ -199,38 +199,20 @@ class IotDevice(Device): return list(self._children.values()) @property - def modules(self) -> dict[str, IotModule]: + def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" + if TYPE_CHECKING: + return cast(ModuleMapping[IotModule], self._modules) return self._modules - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> IotModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | IotModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type.lower() - elif issubclass(module_type, IotModule): - module_name = module_type.__name__.lower() - else: - return None - if module_name in self.modules: - return self.modules[module_name] - return None - - def add_module(self, name: str, module: IotModule): + def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return _LOGGER.debug("Adding module %s", module) - self.modules[name] = module + self._modules[name] = module def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None @@ -292,11 +274,11 @@ class IotDevice(Device): @property # type: ignore @requires_update - def supported_modules(self) -> list[str]: + def supported_modules(self) -> list[str | ModuleName[Module]]: """Return a set of modules supported by the device.""" # TODO: this should rather be called `features`, but we don't want to break # the API now. Maybe just deprecate it and point the users to use this? - return list(self.modules.keys()) + return list(self._modules.keys()) @property # type: ignore @requires_update @@ -325,12 +307,19 @@ class IotDevice(Device): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._modules: + await self._initialize_modules() + + await self._modular_update(req) + if not self._features: await self._initialize_features() - await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + async def _initialize_modules(self): + """Initialize modules not added in init.""" + async def _initialize_features(self): self._add_feature( Feature( @@ -353,29 +342,32 @@ class IotDevice(Device): ) ) + for module in self._modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + async def _modular_update(self, req: dict) -> None: """Execute an update query.""" if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - self.add_module("emeter", Emeter(self, self.emeter_type)) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) # TODO: perhaps modules should not have unsupported modules, # making separate handling for this unnecessary if self._supported_modules is None: supported = {} - for module in self.modules.values(): + for module in self._modules.values(): if module.is_supported: supported[module._module] = module - for module_feat in module._module_features.values(): - self._add_feature(module_feat) self._supported_modules = supported request_list = [] est_response_size = 1024 if "system" in req else 0 - for module in self.modules.values(): + for module in self._modules.values(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue @@ -454,27 +446,27 @@ class IotDevice(Device): @requires_update def time(self) -> datetime: """Return current time from the device.""" - return cast(Time, self.modules["time"]).time + return self.modules[Module.IotTime].time @property @requires_update def timezone(self) -> dict: """Return the current timezone.""" - return cast(Time, self.modules["time"]).timezone + return self.modules[Module.IotTime].timezone async def get_time(self) -> datetime | None: """Return current time from the device, if available.""" _LOGGER.warning( "Use `time` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_time() + return await self.modules[Module.IotTime].get_time() async def get_timezone(self) -> dict: """Return timezone information.""" _LOGGER.warning( "Use `timezone` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_timezone() + return await self.modules[Module.IotTime].get_timezone() @property # type: ignore @requires_update @@ -555,26 +547,26 @@ class IotDevice(Device): def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime) + return EmeterStatus(self.modules[Module.IotEmeter].realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" self._verify_emeter() - return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime()) + return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) @property @requires_update def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_today + return self.modules[Module.IotEmeter].emeter_today @property @requires_update def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_this_month + return self.modules[Module.IotEmeter].emeter_this_month async def get_emeter_daily( self, year: int | None = None, month: int | None = None, kwh: bool = True @@ -588,7 +580,7 @@ class IotDevice(Device): :return: mapping of day of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_daystat( + return await self.modules[Module.IotEmeter].get_daystat( year=year, month=month, kwh=kwh ) @@ -603,15 +595,13 @@ class IotDevice(Device): :return: dict: mapping of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_monthstat( - year=year, kwh=kwh - ) + return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh) @requires_update async def erase_emeter_stats(self) -> dict: """Erase energy meter statistics.""" self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).erase_stats() + return await self.modules[Module.IotEmeter].erase_stats() @requires_update async def current_consumption(self) -> float: @@ -716,9 +706,3 @@ class IotDevice(Device): This should only be used for debugging purposes. """ return self._last_update or self._discovery_info - - @property - @requires_update - def firmware(self) -> Firmware: - """Returns object implementing the firmware handling.""" - return cast(Firmware, self.modules["cloud"]) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index cfe937b8..ef99f749 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,11 +7,11 @@ from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Motion +from .modules import AmbientLight, Light, Motion class ButtonAction(Enum): @@ -79,29 +79,15 @@ class IotDimmer(IotPlug): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support - self.add_module("motion", Motion(self, "smartlife.iot.PIR")) - self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) - - async def _initialize_features(self): - await super()._initialize_features() - - if "brightness" in self.sys_info: # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - unit="%", - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update @@ -110,7 +96,7 @@ class IotDimmer(IotPlug): Will return a range between 0 - 100. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") sys_info = self.sys_info @@ -123,7 +109,7 @@ class IotDimmer(IotPlug): :param int transition: transition duration in milliseconds. Using a transition will cause the dimmer to turn on. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): @@ -232,7 +218,7 @@ class IotDimmer(IotPlug): @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 57b3282f..6bc56258 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -4,10 +4,12 @@ from __future__ import annotations from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..module import Module from ..protocol import BaseProtocol +from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update +from .modules.lighteffect import LightEffect class IotLightStrip(IotBulb): @@ -55,6 +57,14 @@ class IotLightStrip(IotBulb): super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() + self.add_module( + Module.LightEffect, + LightEffect(self, "smartlife.iot.lighting_effect"), + ) + @property # type: ignore @requires_update def length(self) -> int: @@ -73,6 +83,8 @@ class IotLightStrip(IotBulb): 'id': '', 'name': ''} """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility return self.sys_info["lighting_effect_state"] @property # type: ignore @@ -83,6 +95,8 @@ class IotLightStrip(IotBulb): Example: ['Aurora', 'Bubbling Cauldron', ...] """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility return EFFECT_NAMES_V1 if self.has_effects else None @requires_update @@ -105,15 +119,9 @@ class IotLightStrip(IotBulb): :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - if effect not in EFFECT_MAPPING_V1: - raise KasaException(f"The effect {effect} is not a built in effect.") - effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition - - await self.set_custom_effect(effect_dict) + await self.modules[Module.LightEffect].set_effect( + effect, brightness=brightness, transition=transition + ) @requires_update async def set_custom_effect( @@ -126,8 +134,4 @@ class IotLightStrip(IotBulb): """ if not self.has_effects: raise KasaException("Bulb does not support effects.") - await self._query_helper( - "smartlife.iot.lighting_effect", - "set_lighting_effect", - effect_dict, - ) + await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index d8fb4812..ca0c3adb 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -43,13 +43,19 @@ class IotModule(Module): @property def data(self): """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if self._module not in dev._last_update: raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) - return self._device._last_update[self._module] + return dev._last_update[self._module] @property def is_supported(self) -> bool: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index dadb38f2..07226178 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,10 +6,10 @@ import logging from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, Schedule, Time, Usage +from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -53,26 +53,16 @@ class IotPlug(IotDevice): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("time", Time(self, "time")) - self.add_module("cloud", Cloud(self, "cnCloud")) - async def _initialize_features(self): - await super()._initialize_features() - - self._add_feature( - Feature( - device=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - ) - ) + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.Led, Led(self, "system")) @property # type: ignore @requires_update @@ -93,14 +83,11 @@ class IotPlug(IotDevice): @requires_update def led(self) -> bool: """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) + return self.modules[Module.Led].led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - return await self._query_helper( - "system", "set_led_off", {"off": int(not state)} - ) + return await self.modules[Module.Led].set_led(state) class IotWallSwitch(IotPlug): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9e99a074..c4dcc57f 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,6 +10,7 @@ from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -95,11 +96,11 @@ class IotStrip(IotDevice): super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("time", Time(self, "time")) - self.add_module("countdown", Countdown(self, "countdown")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) @property # type: ignore @requires_update @@ -253,8 +254,11 @@ class IotStripPlug(IotPlug): self._last_update = parent._last_update self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket - self._modules = {} self.protocol = parent.protocol # Must use the same connection as the parent + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 41e03bbd..2d6f6a01 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,6 +5,9 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter +from .led import Led +from .light import Light +from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,6 +20,9 @@ __all__ = [ "Cloud", "Countdown", "Emeter", + "Led", + "Light", + "LightEffect", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 4606442e..4effc0c0 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,19 +4,22 @@ from __future__ import annotations import logging from datetime import date -from typing import Optional +from typing import Callable, Coroutine, Optional from pydantic.v1 import BaseModel, Field, validator from ...feature import Feature -from ...firmware import ( +from ...interfaces.firmware import ( Firmware, UpdateResult, ) -from ...firmware import ( - FirmwareUpdate as FirmwareUpdateInterface, +from ...interfaces.firmware import ( + FirmwareDownloadState as FirmwareDownloadStateInterface, ) -from ..iotmodule import IotModule, merge +from ...interfaces.firmware import ( + FirmwareUpdateInfo as FirmwareUpdateInfoInterface, +) +from ..iotmodule import IotModule _LOGGER = logging.getLogger(__name__) @@ -89,8 +92,11 @@ class Cloud(IotModule, Firmware): # TODO: this is problematic, as it will fail the whole query on some # devices if they are not connected to the internet - if self._module in self._device._last_update and self.is_connected: - req = merge(req, self.get_available_firmwares()) + + # The following causes a recursion error as self.is_connected + # accesses self.data which calls query. Also get_available_firmwares is async + # if self._module in self._device._last_update and self.is_connected: + # req = merge(req, self.get_available_firmwares()) return req @@ -130,7 +136,12 @@ class Cloud(IotModule, Firmware): """Disconnect from the cloud.""" return await self.call("unbind") - async def update_firmware(self, *, progress_cb=None) -> UpdateResult: + async def update_firmware( + self, + *, + progress_cb: Callable[[FirmwareDownloadStateInterface], Coroutine] + | None = None, + ) -> UpdateResult: """Perform firmware update.""" raise NotImplementedError i = 0 @@ -144,11 +155,16 @@ class Cloud(IotModule, Firmware): return UpdateResult("") - async def check_for_updates(self) -> FirmwareUpdateInterface: + async def check_for_updates(self) -> FirmwareUpdateInfoInterface: + """Return firmware update information.""" + # TODO: naming of the common firmware API methods + raise NotImplementedError + + async def get_update_state(self) -> FirmwareUpdateInfoInterface: """Return firmware update information.""" fw = await self.get_firmware_update() - return FirmwareUpdateInterface( + return FirmwareUpdateInfoInterface( update_available=fw.update_available, current_version=self._device.hw_info.get("sw_ver"), available_version=fw.version, diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py new file mode 100644 index 00000000..6c4ca02a --- /dev/null +++ b/kasa/iot/modules/led.py @@ -0,0 +1,32 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +from ..iotmodule import IotModule + + +class Led(IotModule, LedInterface): + """Implementation of led controls.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def mode(self): + """LED mode setting. + + "always", "never" + """ + return "always" if self.led else "never" + + @property + def led(self) -> bool: + """Return the state of the led.""" + sys_info = self.data + return bool(1 - sys_info["led_off"]) + + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + return await self.call("set_led_off", {"off": int(not state)}) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 00000000..1bebf817 --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,200 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ...device_type import DeviceType +from ...exceptions import KasaException +from ...feature import Feature +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Light(IotModule, LightInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + device = self._device + + if self._device.is_dimmable: + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + if self._device.is_variable_color_temp: + self._add_feature( + Feature( + device=device, + id="color_temperature", + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + if self._device.is_color: + self._add_feature( + Feature( + device=device, + id="hsv", + name="HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + def _get_bulb_device(self) -> IotBulb | None: + """For type checker this gets an IotBulb. + + IotDimmer is not a subclass of IotBulb and using isinstance + here at runtime would create a circular import. + """ + if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}: + return cast("IotBulb", self._device) + return None + + @property # type: ignore + def is_dimmable(self) -> int: + """Whether the bulb supports brightness changes.""" + return self._device._is_dimmable + + @property # type: ignore + def brightness(self) -> int: + """Return the current brightness in percentage.""" + return self._device.brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + return await self._device.set_brightness(brightness, transition=transition) + + @property + def is_color(self) -> bool: + """Whether the light supports color changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb._is_color + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb._is_variable_color_temp + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.has_effects + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: + raise KasaException("Light does not support color.") + return bulb.hsv + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: + raise KasaException("Light does not support color.") + return await bulb.set_hsv(hue, saturation, value, transition=transition) + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.valid_temperature_range + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.color_temp + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = 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. + """ + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return await bulb.set_color_temp( + temp, brightness=brightness, transition=transition + ) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py new file mode 100644 index 00000000..2d40fb54 --- /dev/null +++ b/kasa/iot/modules/lighteffect.py @@ -0,0 +1,97 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...interfaces.lighteffect import LightEffect as LightEffectInterface +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..iotmodule import IotModule + + +class LightEffect(IotModule, LightEffectInterface): + """Implementation of dynamic light effects.""" + + @property + def effect(self) -> str: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + if ( + (state := self.data.get("lighting_effect_state")) + and state.get("enable") + and (name := state.get("name")) + and name in EFFECT_NAMES_V1 + ): + return name + return self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES_V1) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + effect_dict = dict(self.data["lighting_effect_state"]) + effect_dict["enable"] = 0 + elif effect not in EFFECT_MAPPING_V1: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self): + """Return the base query.""" + return {} diff --git a/kasa/module.py b/kasa/module.py index 3da0c1ad..61258f69 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -6,14 +6,19 @@ import logging from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Final, TypeVar, ) from .exceptions import KasaException from .feature import Feature +from .modulemapping import ModuleName if TYPE_CHECKING: + from . import interfaces from .device import Device + from .iot import modules as iot + from .smart import modules as smart _LOGGER = logging.getLogger(__name__) @@ -27,6 +32,59 @@ class Module(ABC): executed during the regular update cycle. """ + # Common Modules + LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") + Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") + Firmware: Final[ModuleName[interfaces.Firmware]] = ModuleName("Firmware") + + # IOT only Modules + IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") + IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") + IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") + IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") + IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") + IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") + IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") + + # SMART only Modules + Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") + AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") + Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") + Color: Final[ModuleName[smart.Color]] = ModuleName("Color") + ColorTemperature: Final[ModuleName[smart.ColorTemperature]] = ModuleName( + "ColorTemperature" + ) + ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") + DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy") + Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan") + FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( + "FrostProtection" + ) + HumiditySensor: Final[ModuleName[smart.HumiditySensor]] = ModuleName( + "HumiditySensor" + ) + LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( + "LightTransition" + ) + ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") + TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + "TemperatureSensor" + ) + TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + "TemperatureControl" + ) + Time: Final[ModuleName[smart.Time]] = ModuleName("Time") + WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( + "WaterleakSensor" + ) + def __init__(self, device: Device, module: str): self._device = device self._module = module diff --git a/kasa/modulemapping.py b/kasa/modulemapping.py new file mode 100644 index 00000000..06ba8619 --- /dev/null +++ b/kasa/modulemapping.py @@ -0,0 +1,25 @@ +"""Module for Implementation for ModuleMapping and ModuleName types. + +Custom dict for getting typed modules from the module dict. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from .module import Module + +_ModuleT = TypeVar("_ModuleT", bound="Module") + + +class ModuleName(str, Generic[_ModuleT]): + """Generic Module name type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +ModuleMapping = dict diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi new file mode 100644 index 00000000..8d110d39 --- /dev/null +++ b/kasa/modulemapping.pyi @@ -0,0 +1,96 @@ +"""Typing stub file for ModuleMapping.""" + +from abc import ABCMeta +from collections.abc import Mapping +from typing import Generic, TypeVar, overload + +from .module import Module + +__all__ = [ + "ModuleMapping", + "ModuleName", +] + +_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True) +_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True) + +class ModuleName(Generic[_ModuleT]): + """Class for typed Module names. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class ModuleMapping( + Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta +): + """Custom dict type to provide better value type hints for Module key types.""" + + @overload + def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ... + @overload + def __getitem__(self, key: str, /) -> _ModuleBaseT: ... + @overload + def __getitem__( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT: ... + @overload # type: ignore[override] + def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ... + @overload + def get(self, key: str, /) -> _ModuleBaseT | None: ... + @overload + def get( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT | None: ... + +def _test_module_mapping_typing() -> None: + """Test ModuleMapping overloads work as intended. + + This is tested during the mypy run and needs to be in this file. + """ + from typing import Any, NewType, cast + + from typing_extensions import assert_type + + from .iot.iotmodule import IotModule + from .module import Module + from .smart.smartmodule import SmartModule + + NewCommonModule = NewType("NewCommonModule", Module) + NewIotModule = NewType("NewIotModule", IotModule) + NewSmartModule = NewType("NewSmartModule", SmartModule) + NotModule = NewType("NotModule", list) + + NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule") + NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule") + NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule") + + # TODO Enable --warn-unused-ignores + NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841 + NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841 + + device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {}) + assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule) + assert_type(device_modules[NEW_IOT_MODULE], NewIotModule) + assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule) + assert_type(device_modules["foobar"], Module) + assert_type(device_modules[3], Any) # type: ignore[call-overload] + + assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None) + assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None) + assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None) + assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload] + + iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {}) + smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {}) + + assert_type(smart_modules["foobar"], SmartModule) + assert_type(iot_modules["foobar"], IotModule) + + # Test for covariance + device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841 + device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841 + NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841 + NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841 diff --git a/kasa/plug.py b/kasa/plug.py deleted file mode 100644 index 00796d1c..00000000 --- a/kasa/plug.py +++ /dev/null @@ -1,12 +0,0 @@ -"""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/modules/__init__.py b/kasa/smart/modules/__init__.py index 64722079..b295bcb2 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,49 +1,53 @@ """Modules for SMART devices.""" -from .alarmmodule import AlarmModule -from .autooffmodule import AutoOffModule -from .battery import BatterySensor +from .alarm import Alarm +from .autooff import AutoOff +from .batterysensor import BatterySensor from .brightness import Brightness -from .childdevicemodule import ChildDeviceModule -from .cloudmodule import CloudModule -from .colormodule import ColorModule -from .colortemp import ColorTemperatureModule +from .childdevice import ChildDevice +from .cloud import Cloud +from .color import Color +from .colortemperature import ColorTemperature +from .contactsensor import ContactSensor from .devicemodule import DeviceModule -from .energymodule import EnergyModule -from .fanmodule import FanModule +from .energy import Energy +from .fan import Fan from .firmware import Firmware -from .frostprotection import FrostProtectionModule -from .humidity import HumiditySensor -from .ledmodule import LedModule -from .lighteffectmodule import LightEffectModule -from .lighttransitionmodule import LightTransitionModule -from .reportmodule import ReportModule -from .temperature import TemperatureSensor +from .frostprotection import FrostProtection +from .humiditysensor import HumiditySensor +from .led import Led +from .light import Light +from .lighteffect import LightEffect +from .lighttransition import LightTransition +from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl -from .timemodule import TimeModule -from .waterleak import WaterleakSensor +from .temperaturesensor import TemperatureSensor +from .time import Time +from .waterleaksensor import WaterleakSensor __all__ = [ - "AlarmModule", - "TimeModule", - "EnergyModule", + "Alarm", + "Time", + "Energy", "DeviceModule", - "ChildDeviceModule", + "ChildDevice", "BatterySensor", "HumiditySensor", "TemperatureSensor", "TemperatureControl", - "ReportModule", - "AutoOffModule", - "LedModule", + "ReportMode", + "AutoOff", + "Led", "Brightness", - "FanModule", + "Fan", "Firmware", - "CloudModule", - "LightEffectModule", - "LightTransitionModule", - "ColorTemperatureModule", - "ColorModule", + "Cloud", + "Light", + "LightEffect", + "LightTransition", + "ColorTemperature", + "Color", "WaterleakSensor", - "FrostProtectionModule", + "ContactSensor", + "FrostProtection", ] diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarm.py similarity index 99% rename from kasa/smart/modules/alarmmodule.py rename to kasa/smart/modules/alarm.py index 845eb65a..f033496a 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarm.py @@ -6,7 +6,7 @@ from ...feature import Feature from ..smartmodule import SmartModule -class AlarmModule(SmartModule): +class Alarm(SmartModule): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooff.py similarity index 98% rename from kasa/smart/modules/autooffmodule.py rename to kasa/smart/modules/autooff.py index cb8d5e57..385364fa 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooff.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class AutoOffModule(SmartModule): +class AutoOff(SmartModule): """Implementation of auto off module.""" REQUIRED_COMPONENT = "auto_off" diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/batterysensor.py similarity index 58% rename from kasa/smart/modules/battery.py rename to kasa/smart/modules/batterysensor.py index 6f914bdf..415e47d1 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/batterysensor.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class BatterySensor(SmartModule): """Implementation of battery module.""" @@ -17,23 +12,11 @@ class BatterySensor(SmartModule): REQUIRED_COMPONENT = "battery_detect" QUERY_GETTER_NAME = "get_battery_detect_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" self._add_feature( Feature( - device, - "battery_level", - "Battery level", - container=self, - attribute_getter="battery", - icon="mdi:battery", - unit="%", - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, + self._device, "battery_low", "Battery low", container=self, @@ -44,6 +27,22 @@ class BatterySensor(SmartModule): ) ) + # Some devices, like T110 contact sensor do not report the battery percentage + if "battery_percentage" in self._device.sys_info: + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit="%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + @property def battery(self): """Return battery level.""" diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b0b58c07..fbd90808 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -2,16 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - -BRIGHTNESS_MIN = 1 +BRIGHTNESS_MIN = 0 BRIGHTNESS_MAX = 100 @@ -20,8 +14,11 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + + device = self._device self._add_feature( Feature( device, @@ -47,8 +44,11 @@ class Brightness(SmartModule): """Return current brightness.""" return self.data["brightness"] - async def set_brightness(self, brightness: int): - """Set the brightness.""" + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set the brightness. A brightness value of 0 will turn off the light. + + Note, transition is not supported and will be ignored. + """ if not isinstance(brightness, int) or not ( BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX ): @@ -57,6 +57,8 @@ class Brightness(SmartModule): f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" ) + if brightness == 0: + return await self._device.turn_off() return await self.call("set_device_info", {"brightness": brightness}) async def _check_supported(self): diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevice.py similarity index 84% rename from kasa/smart/modules/childdevicemodule.py rename to kasa/smart/modules/childdevice.py index 9f4710b2..5713eff4 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevice.py @@ -3,7 +3,7 @@ from ..smartmodule import SmartModule -class ChildDeviceModule(SmartModule): +class ChildDevice(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloud.py similarity index 97% rename from kasa/smart/modules/cloudmodule.py rename to kasa/smart/modules/cloud.py index 8b9d8f41..1b64f090 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloud.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class CloudModule(SmartModule): +class Cloud(SmartModule): """Implementation of cloud module.""" QUERY_GETTER_NAME = "get_connect_cloud_state" diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/color.py similarity index 97% rename from kasa/smart/modules/colormodule.py rename to kasa/smart/modules/color.py index 716d4c44..88d02908 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/color.py @@ -4,15 +4,15 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ...bulb import HSV from ...feature import Feature +from ...interfaces.light import HSV from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class ColorModule(SmartModule): +class Color(SmartModule): """Implementation of color module.""" REQUIRED_COMPONENT = "color" diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemperature.py similarity index 96% rename from kasa/smart/modules/colortemp.py rename to kasa/smart/modules/colortemperature.py index d6b43d02..fa3b7412 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemperature.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from ...bulb import ColorTempRange from ...feature import Feature +from ...interfaces.light import ColorTempRange from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_TEMP_RANGE = [2500, 6500] -class ColorTemperatureModule(SmartModule): +class ColorTemperature(SmartModule): """Implementation of color temp module.""" REQUIRED_COMPONENT = "color_temperature" diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py new file mode 100644 index 00000000..7932a081 --- /dev/null +++ b/kasa/smart/modules/contactsensor.py @@ -0,0 +1,42 @@ +"""Implementation of contact sensor module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ContactSensor(SmartModule): + """Implementation of contact sensor module.""" + + REQUIRED_COMPONENT = None # we depend on availability of key + REQUIRED_KEY_ON_PARENT = "open" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + id="is_open", + name="Open", + container=self, + attribute_getter="is_open", + icon="mdi:door", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_open(self): + """Return True if the contact sensor is open.""" + return self._device.sys_info["open"] diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energy.py similarity index 98% rename from kasa/smart/modules/energymodule.py rename to kasa/smart/modules/energy.py index 9cfe8cfb..55b5088e 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energy.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class EnergyModule(SmartModule): +class Energy(SmartModule): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fan.py similarity index 98% rename from kasa/smart/modules/fanmodule.py rename to kasa/smart/modules/fan.py index 6eeaa4d4..3d8cc7eb 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fan.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class FanModule(SmartModule): +class Fan(SmartModule): """Implementation of fan_control module.""" REQUIRED_COMPONENT = "fan_control" diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 81c7b62b..20722122 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -5,10 +5,8 @@ from __future__ import annotations import asyncio import logging from datetime import date -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional -# When support for cpython older than 3.11 is dropped -# async_timeout can be replaced with asyncio.timeout # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout @@ -16,9 +14,12 @@ from pydantic.v1 import BaseModel, Field, validator from ...exceptions import SmartErrorCode from ...feature import Feature -from ...firmware import Firmware as FirmwareInterface -from ...firmware import FirmwareUpdate as FirmwareUpdateInterface -from ...firmware import UpdateResult +from ...interfaces import Firmware as FirmwareInterface +from ...interfaces.firmware import ( + FirmwareDownloadState as FirmwareDownloadStateInterface, +) +from ...interfaces.firmware import FirmwareUpdateInfo as FirmwareUpdateInfoInterface +from ...interfaces.firmware import UpdateResult from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -28,7 +29,20 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -class FirmwareUpdate(BaseModel): +class DownloadState(BaseModel): + """Download state.""" + + # Example: + # {'status': 0, 'download_progress': 0, 'reboot_time': 5, + # 'upgrade_time': 5, 'auto_upgrade': False} + status: int + progress: int = Field(alias="download_progress") + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + +class FirmwareUpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") @@ -91,7 +105,7 @@ class Firmware(SmartModule, FirmwareInterface): name="Current firmware version", container=self, attribute_getter="current_firmware", - category=Feature.Category.Info, + category=Feature.Category.Debug, ) ) self._add_feature( @@ -101,7 +115,7 @@ class Firmware(SmartModule, FirmwareInterface): name="Available firmware version", container=self, attribute_getter="latest_firmware", - category=Feature.Category.Info, + category=Feature.Category.Debug, ) ) @@ -128,9 +142,9 @@ class Firmware(SmartModule, FirmwareInterface): fw = self.data.get("get_latest_fw") or self.data if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): # Error in response, probably disconnected from the cloud. - return FirmwareUpdate(type=0, need_to_upgrade=False) + return FirmwareUpdateInfo(type=0, need_to_upgrade=False) - return FirmwareUpdate.parse_obj(fw) + return FirmwareUpdateInfo.parse_obj(fw) @property def update_available(self) -> bool | None: @@ -139,31 +153,60 @@ class Firmware(SmartModule, FirmwareInterface): return None return self.firmware_update_info.update_available - async def get_update_state(self): + async def get_update_state(self) -> DownloadState: """Return update state.""" - return await self.call("get_fw_download_state") + resp = await self.call("get_fw_download_state") + state = resp["get_fw_download_state"] + return DownloadState(**state) - async def update(self): + async def update( + self, progress_cb: Callable[[DownloadState], Coroutine] | None = None + ): """Update the device firmware.""" current_fw = self.current_firmware - _LOGGER.debug( + _LOGGER.info( "Going to upgrade from %s to %s", current_fw, self.firmware_update_info.version, ) - resp = await self.call("fw_download") - _LOGGER.debug("Update request response: %s", resp) + await self.call("fw_download") + # TODO: read timeout from get_auto_update_info or from get_fw_download_state? async with asyncio_timeout(60 * 5): while True: await asyncio.sleep(0.5) - state = await self.get_update_state() - _LOGGER.debug("Update state: %s" % state) - # TODO: this could await a given callable for progress + try: + state = await self.get_update_state() + except Exception as ex: + _LOGGER.warning( + "Got exception, maybe the device is rebooting? %s", ex + ) + continue - if self.firmware_update_info.version != current_fw: - _LOGGER.info("Updated to %s", self.firmware_update_info.version) + _LOGGER.debug("Update state: %s" % state) + if progress_cb is not None: + asyncio.create_task(progress_cb(state)) + + if state.status == 0: + _LOGGER.info( + "Update idle, hopefully updated to %s", + self.firmware_update_info.version, + ) break + elif state.status == 2: + _LOGGER.info("Downloading firmware, progress: %s", state.progress) + elif state.status == 3: + upgrade_sleep = state.upgrade_time + _LOGGER.info( + "Flashing firmware, sleeping for %s before checking status", + upgrade_sleep, + ) + await asyncio.sleep(upgrade_sleep) + elif state.status < 0: + _LOGGER.error("Got error: %s", state.status) + break + else: + _LOGGER.warning("Unhandled state code: %s", state) @property def auto_update_enabled(self): @@ -178,16 +221,21 @@ class Firmware(SmartModule, FirmwareInterface): data = {**self.data["get_auto_update_info"], "enable": enabled} await self.call("set_auto_update_info", data) - async def update_firmware(self, *, progress_cb) -> UpdateResult: + async def update_firmware( + self, + *, + progress_cb: Callable[[FirmwareDownloadStateInterface], Coroutine] + | None = None, + ) -> UpdateResult: """Update the firmware.""" # TODO: implement, this is part of the common firmware API raise NotImplementedError - async def check_for_updates(self) -> FirmwareUpdateInterface: + async def check_for_updates(self) -> FirmwareUpdateInfoInterface: """Return firmware update information.""" # TODO: naming of the common firmware API methods info = self.firmware_update_info - return FirmwareUpdateInterface( + return FirmwareUpdateInfoInterface( current_version=self.current_firmware, update_available=info.update_available, available_version=info.version, diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index cedaf78b..ee93d299 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class FrostProtectionModule(SmartModule): +class FrostProtection(SmartModule): """Implementation for frost protection module. This basically turns the thermostat on and off. diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humiditysensor.py similarity index 100% rename from kasa/smart/modules/humidity.py rename to kasa/smart/modules/humiditysensor.py diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/led.py similarity index 61% rename from kasa/smart/modules/ledmodule.py rename to kasa/smart/modules/led.py index e3113159..230b83d9 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/led.py @@ -2,37 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from ...feature import Feature +from ...interfaces.led import Led as LedInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - -class LedModule(SmartModule): +class Led(SmartModule, LedInterface): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device=device, - container=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"led_rule": None}} @@ -56,7 +35,7 @@ class LedModule(SmartModule): This should probably be a select with always/never/nightmode. """ rule = "always" if enable else "never" - return await self.call("set_led_info", self.data | {"led_rule": rule}) + return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) @property def night_mode_settings(self): diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py new file mode 100644 index 00000000..88d6486b --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,126 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...exceptions import KasaException +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ...module import Module +from ..smartmodule import SmartModule + + +class Light(SmartModule, LightInterface): + """Implementation of a light.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return Module.Color in self._device.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return Module.Brightness in self._device.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return Module.ColorTemperature in self._device.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise KasaException("Color temperature not supported") + + return self._device.modules[Module.ColorTemperature].valid_temperature_range + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return self._device.modules[Module.Color].hsv + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + return self._device.modules[Module.ColorTemperature].color_temp + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return self._device.modules[Module.Brightness].brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = 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 between 1 and 100 + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = 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. + """ + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return await self._device.modules[Module.Brightness].set_brightness(brightness) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return Module.LightEffect in self._device.modules diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffect.py similarity index 80% rename from kasa/smart/modules/lighteffectmodule.py rename to kasa/smart/modules/lighteffect.py index bd0eea0a..4f049576 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffect.py @@ -6,14 +6,14 @@ import base64 import copy from typing import TYPE_CHECKING, Any -from ...feature import Feature +from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule): +class LightEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" @@ -22,29 +22,11 @@ class LightEffectModule(SmartModule): "L1": "Party", "L2": "Relax", } - LIGHT_EFFECTS_OFF = "Off" def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._scenes_names_to_id: dict[str, str] = {} - def _initialize_features(self): - """Initialize features.""" - device = self._device - self._add_feature( - Feature( - device, - id="light_effect", - name="Light effect", - container=self, - attribute_getter="effect", - attribute_setter="set_effect", - category=Feature.Category.Config, - type=Feature.Type.Choice, - choices_getter="effect_list", - ) - ) - def _initialize_effects(self) -> dict[str, dict[str, Any]]: """Return built-in effects.""" # Copy the effects so scene name updates do not update the underlying dict. @@ -64,7 +46,7 @@ class LightEffectModule(SmartModule): return effects @property - def effect_list(self) -> list[str] | None: + def effect_list(self) -> list[str]: """Return built-in effects list. Example: @@ -90,6 +72,9 @@ class LightEffectModule(SmartModule): async def set_effect( self, effect: str, + *, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect for the device. @@ -108,6 +93,24 @@ class LightEffectModule(SmartModule): params["id"] = effect_id return await self.call("set_dynamic_light_effect_rule_enable", params) + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError( + "Device does not support setting custom effects. " + "Use has_custom_effects to check for support." + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransition.py similarity index 99% rename from kasa/smart/modules/lighttransitionmodule.py rename to kasa/smart/modules/lighttransition.py index f213d9ac..a11c7d95 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransition.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightTransitionModule(SmartModule): +class LightTransition(SmartModule): """Implementation of gradual on/off.""" REQUIRED_COMPONENT = "on_off_gradually" diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmode.py similarity index 96% rename from kasa/smart/modules/reportmodule.py rename to kasa/smart/modules/reportmode.py index 16827a8c..f0af4c1c 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmode.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class ReportModule(SmartModule): +class ReportMode(SmartModule): """Implementation of report module.""" REQUIRED_COMPONENT = "report_mode" diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperaturesensor.py similarity index 100% rename from kasa/smart/modules/temperature.py rename to kasa/smart/modules/temperaturesensor.py diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/time.py similarity index 98% rename from kasa/smart/modules/timemodule.py rename to kasa/smart/modules/time.py index 23814f57..958cf9e2 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/time.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class TimeModule(SmartModule): +class Time(SmartModule): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "time" diff --git a/kasa/smart/modules/waterleak.py b/kasa/smart/modules/waterleaksensor.py similarity index 100% rename from kasa/smart/modules/waterleak.py rename to kasa/smart/modules/waterleaksensor.py diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 7f747b84..d841d2d9 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,6 +49,7 @@ class SmartChildDevice(SmartDevice): """Return child device type.""" child_device_map = { "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 17f9d359..e4260995 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,32 +5,24 @@ from __future__ import annotations import base64 import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..fan import Fan from ..feature import Feature -from ..firmware import Firmware -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( - Brightness, - CloudModule, - ColorModule, - ColorTemperatureModule, + Cloud, DeviceModule, - EnergyModule, - FanModule, - TimeModule, -) -from .modules import ( - Firmware as FirmwareModule, + Firmware, + Light, + Time, ) from .smartmodule import SmartModule @@ -41,12 +33,12 @@ _LOGGER = logging.getLogger(__name__) # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the # same issue, homekit perhaps? -WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Bulb, Fan, Device): +class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -64,7 +56,7 @@ class SmartDevice(Bulb, Fan, Device): self._components_raw: dict[str, Any] | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str, SmartModule] = {} + self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} @@ -105,8 +97,20 @@ class SmartDevice(Bulb, Fan, Device): return list(self._children.values()) @property - def modules(self) -> dict[str, SmartModule]: + def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" + if self._exposes_child_modules: + modules = {k: v for k, v in self._modules.items()} + for child in self._children.values(): + for k, v in child._modules.items(): + if k not in modules: + modules[k] = v + if TYPE_CHECKING: + return cast(ModuleMapping[SmartModule], modules) + return modules + + if TYPE_CHECKING: # Needed for python 3.8 + return cast(ModuleMapping[SmartModule], self._modules) return self._modules def _try_get_response(self, responses: dict, request: str, default=None) -> dict: @@ -213,7 +217,10 @@ class SmartDevice(Bulb, Fan, Device): skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: continue - if mod.REQUIRED_COMPONENT in self._components: + if ( + mod.REQUIRED_COMPONENT in self._components + or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + ): _LOGGER.debug( "Found required %s, adding %s to modules.", mod.REQUIRED_COMPONENT, @@ -223,6 +230,13 @@ class SmartDevice(Bulb, Fan, Device): if await module._check_supported(): self._modules[module.name] = module + if ( + Module.Brightness in self._modules + or Module.Color in self._modules + or Module.ColorTemperature in self._modules + ): + self._modules[Light.__name__] = Light(self, "light") + async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -310,41 +324,20 @@ class SmartDevice(Bulb, Fan, Device): ) ) - for module in self._modules.values(): - module._initialize_features() + for module in self.modules.values(): + # Check if module features have already been initialized. + # i.e. when _exposes_child_modules is true + if not module._module_features: + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> SmartModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | SmartModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type - elif issubclass(module_type, SmartModule): - module_name = module_type.__name__ - else: - return None - if module_name in self.modules: - return self.modules[module_name] - elif self._exposes_child_modules: - for child in self._children.values(): - if module_name in child.modules: - return child.modules[module_name] - return None - @property - def is_cloud_connected(self): + def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud.""" - if "CloudModule" not in self.modules: + if Module.Cloud not in self.modules: return False - return self.modules["CloudModule"].is_connected + return self.modules[Module.Cloud].is_connected @property def sys_info(self) -> dict[str, Any]: @@ -368,10 +361,10 @@ class SmartDevice(Bulb, Fan, Device): def time(self) -> datetime: """Return the time.""" # TODO: Default to parent's time module for child devices - if self._parent and "TimeModule" in self.modules: - _timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 + if self._parent and Module.Time in self.modules: + _timemod = self._parent.modules[Module.Time] else: - _timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 + _timemod = self.modules[Module.Time] return _timemod.time @@ -448,12 +441,7 @@ class SmartDevice(Bulb, Fan, Device): @property def has_emeter(self) -> bool: """Return if the device has emeter.""" - return "EnergyModule" in self.modules - - @property - def is_dimmer(self) -> bool: - """Whether the device acts as a dimmer.""" - return self.is_dimmable + return Module.Energy in self.modules @property def is_on(self) -> bool: @@ -490,19 +478,19 @@ class SmartDevice(Bulb, Fan, Device): @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_realtime @property def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_this_month @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_today @property @@ -514,8 +502,7 @@ class SmartDevice(Bulb, Fan, Device): ): return None on_time = cast(float, on_time) - if (timemod := self.modules.get("TimeModule")) is not None: - timemod = cast(TimeModule, timemod) # noqa: F405 + if (timemod := self.modules.get(Module.Time)) is not None: return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @@ -628,12 +615,6 @@ class SmartDevice(Bulb, Fan, Device): return self._device_type - @property - def firmware(self) -> Firmware: - """Return firmware module.""" - # TODO: open question: does it make sense to expose common modules? - return cast(Firmware, self.get_module(FirmwareModule)) - @staticmethod def _get_device_type_from_components( components: list[str], device_type: str @@ -661,149 +642,3 @@ class SmartDevice(Bulb, Fan, Device): return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - - # Fan interface methods - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return "FanModule" in self.modules - - @property - def fan_speed_level(self) -> int: - """Return fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return cast(FanModule, self.modules["FanModule"]).fan_speed_level - - async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) - - # Bulb interface methods - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return "ColorModule" in self.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return "Brightness" in self.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return "ColorTemperatureModule" in self.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).valid_temperature_range - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return cast(ColorModule, self.modules["ColorModule"]).hsv - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).color_temp - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return cast(Brightness, self.modules["Brightness"]).brightness - - async def set_hsv( - self, - hue: int, - saturation: int, - value: int | None = None, - *, - transition: int | None = 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 between 1 and 100 - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( - hue, saturation, value - ) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = 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. - """ - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - return await cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).set_color_temp(temp) - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) - - @property - def presets(self) -> list[BulbPreset]: - """Return a list of available bulb setting presets.""" - return [] - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "LightEffectModule" in self.modules diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 9169b752..e78f4393 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -18,8 +18,13 @@ class SmartModule(Module): """Base class for SMART modules.""" NAME: str - REQUIRED_COMPONENT: str + #: Module is initialized, if the given component is available + REQUIRED_COMPONENT: str | None = None + #: Module is initialized, if the given key available in the main sysinfo + REQUIRED_KEY_ON_PARENT: str | None = None + #: Query to execute during the main update cycle QUERY_GETTER_NAME: str + REGISTERED_MODULES: dict[str, type[SmartModule]] = {} def __init__(self, device: SmartDevice, module: str): @@ -27,8 +32,6 @@ class SmartModule(Module): super().__init__(device, module) def __init_subclass__(cls, **kwargs): - assert cls.REQUIRED_COMPONENT is not None # noqa: S101 - name = getattr(cls, "NAME", cls.__name__) _LOGGER.debug("Registering %s" % cls) cls.REGISTERED_MODULES[name] = cls @@ -91,8 +94,13 @@ class SmartModule(Module): @property def supported_version(self) -> int: - """Return version supported by the device.""" - return self._device._components[self.REQUIRED_COMPONENT] + """Return version supported by the device. + + If the module has no required component, this will return -1. + """ + if self.REQUIRED_COMPONENT is not None: + return self._device._components[self.REQUIRED_COMPONENT] + return -1 async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 92a86b6f..e8fbeeec 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -109,7 +109,7 @@ DIMMERS = { } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300"} +SENSORS_SMART = {"T310", "T315", "T300", "T110"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} @@ -203,14 +203,14 @@ wallswitch_iot = parametrize( "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} ) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize( +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} ) # bulb types -dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize( +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} ) variable_temp = parametrize( @@ -292,12 +292,12 @@ device_iot = parametrize( def check_categories(): """Check that every fixture file is categorized.""" categorized_fixtures = set( - dimmer.args[1] + dimmer_iot.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + wallswitch.args[1] - + lightstrip.args[1] + + lightstrip_iot.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index ae1a7ad6..7c73c71e 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -189,6 +189,11 @@ class FakeSmartTransport(BaseTransport): if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_led_info(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -218,7 +223,9 @@ class FakeSmartTransport(BaseTransport): # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - result = copy.deepcopy(missing_result[1]) + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called @@ -234,11 +241,14 @@ class FakeSmartTransport(BaseTransport): pytest.fixtures_missing_methods[self.fixture_name] = set() pytest.fixtures_missing_methods[self.fixture_name].add(method) return retval - elif method == "set_qs_info": + elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) return {"error_code": 0} + elif method == "set_led_info": + self._set_led_info(info, params) + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json new file mode 100644 index 00000000..021309c7 --- /dev/null +++ b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -0,0 +1,547 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_alarm_configure": { + "duration": 10, + "type": "Alarm 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 62, + "current_humidity_exception": 2, + "current_temp": 24.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -38, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 4, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1451 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1714669215 + }, + "get_device_usage": {}, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1259, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json new file mode 100644 index 00000000..cd3a241e --- /dev/null +++ b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json @@ -0,0 +1,170 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713888871, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-02-05", + "release_note": "Modifications and Bug Fixes:\n1. Optimized the noise issue in some cases.\n2. Fixed some minor bugs.", + "type": 2 + } +} diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json new file mode 100644 index 00000000..acf7ae88 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -0,0 +1,526 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t110", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 220728 Rel.160024", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661626, + "mac": "E4FAC4000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -54, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 30, + "reboot_time": 5, + "status": 4, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-30", + "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1714681046, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "8140289c-c66b-bdd6-63b9-542299442299", + "id": 4, + "timestamp": 1714661714 + }, + { + "event": "open", + "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", + "id": 3, + "timestamp": 1714661710 + }, + { + "event": "close", + "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", + "id": 2, + "timestamp": 1714661657 + }, + { + "event": "open", + "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", + "id": 1, + "timestamp": 1714661638 + } + ], + "start_id": 4, + "sum": 4 + } +} diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 02a396aa..e3c3c530 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ import pytest from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable, parametrize +from kasa.tests.conftest import dimmable_iot, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -10,13 +10,13 @@ brightness = parametrize("brightness smart", component_filter="brightness") @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.get_module("Brightness") + brightness = dev.modules.get("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components # Test getting the value - feature = brightness._module_features["brightness"] + feature = dev.features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 @@ -32,7 +32,7 @@ async def test_brightness_component(dev: SmartDevice): await feature.set_value(feature.maximum_value + 10) -@dimmable +@dimmable_iot async def test_brightness_dimmable(dev: IotDevice): """Test brightness feature.""" assert isinstance(dev, IotDevice) diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py new file mode 100644 index 00000000..11440871 --- /dev/null +++ b/kasa/tests/smart/modules/test_contact.py @@ -0,0 +1,28 @@ +import pytest + +from kasa import Module, SmartDevice +from kasa.tests.device_fixtures import parametrize + +contact = parametrize( + "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} +) + + +@contact +@pytest.mark.parametrize( + "feature, type", + [ + ("is_open", bool), + ], +) +async def test_contact_features(dev: SmartDevice, feature, type): + """Test that features are registered and work as expected.""" + contact = dev.modules.get(Module.ContactSensor) + assert contact is not None + + prop = getattr(contact, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 37245951..e5e1ff72 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,8 +1,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -11,10 +11,10 @@ fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"S @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan - level_feature = fan._module_features["fan_speed_level"] + level_feature = dev.features["fan_speed_level"] assert ( level_feature.minimum_value <= level_feature.value @@ -36,9 +36,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan - sleep_feature = fan._module_features["fan_sleep_mode"] + sleep_feature = dev.features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) call = mocker.spy(fan, "call") @@ -52,29 +52,28 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan device = fan._device - assert device.is_fan - await device.set_fan_speed_level(1) + await fan.set_fan_speed_level(1) await dev.update() - assert device.fan_speed_level == 1 + assert fan.fan_speed_level == 1 assert device.is_on - await device.set_fan_speed_level(4) + await fan.set_fan_speed_level(4) await dev.update() - assert device.fan_speed_level == 4 + assert fan.fan_speed_level == 4 - await device.set_fan_speed_level(0) + await fan.set_fan_speed_level(0) await dev.update() assert not device.is_on with pytest.raises(ValueError): - await device.set_fan_speed_level(-1) + await fan.set_fan_speed_level(-1) with pytest.raises(ValueError): - await device.set_fan_speed_level(5) + await fan.set_fan_speed_level(5) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py new file mode 100644 index 00000000..aa71099f --- /dev/null +++ b/kasa/tests/smart/modules/test_firmware.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.firmware import DownloadState, Firmware +from kasa.tests.device_fixtures import parametrize + +firmware = parametrize( + "has firmware", component_filter="firmware", protocol_filter={"SMART"} +) + + +@firmware +@pytest.mark.parametrize( + "feature, prop_name, type, required_version", + [ + ("auto_update_enabled", "auto_update_enabled", bool, 2), + ("update_available", "update_available", bool, 1), + ("update_available", "update_available", bool, 1), + ("current_firmware_version", "current_firmware", str, 1), + ("available_firmware_version", "latest_firmware", str, 1), + ], +) +async def test_firmware_features( + dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture +): + """Test light effect.""" + fw = dev.modules.get(Module.Firmware) + assert fw + if not isinstance(fw, Firmware): # TODO needed while common interface still TBD + return + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + if fw.supported_version < required_version: + pytest.skip("Feature %s requires newer version" % feature) + + prop = getattr(fw, prop_name) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@firmware +async def test_update_available_without_cloud(dev: SmartDevice): + """Test that update_available returns None when disconnected.""" + fw = dev.modules.get(Module.Firmware) + assert fw + if not isinstance(fw, Firmware): # TODO needed while common interface still TBD + return + + if dev.is_cloud_connected: + assert isinstance(fw.update_available, bool) + else: + assert fw.update_available is None + + +@firmware +async def test_firmware_update( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test updating firmware.""" + caplog.set_level(logging.INFO) + + fw = dev.modules.get(Module.Firmware) + assert fw + if not isinstance(fw, Firmware): # TODO needed while common interface still TBD + return + + upgrade_time = 5 + extras = {"reboot_time": 5, "upgrade_time": upgrade_time, "auto_upgrade": False} + update_states = [ + # Unknown 1 + DownloadState(status=1, download_progress=0, **extras), + # Downloading + DownloadState(status=2, download_progress=10, **extras), + DownloadState(status=2, download_progress=100, **extras), + # Flashing + DownloadState(status=3, download_progress=100, **extras), + DownloadState(status=3, download_progress=100, **extras), + # Done + DownloadState(status=0, download_progress=100, **extras), + ] + + asyncio_sleep = asyncio.sleep + sleep = mocker.patch("asyncio.sleep") + mocker.patch.object(fw, "get_update_state", side_effect=update_states) + + cb_mock = mocker.AsyncMock() + + await fw.update(progress_cb=cb_mock) + + # This is necessary to allow the eventloop to process the created tasks + await asyncio_sleep(0) + + assert "Unhandled state code" in caplog.text + assert "Downloading firmware, progress: 10" in caplog.text + assert "Flashing firmware, sleeping" in caplog.text + assert "Update idle" in caplog.text + + for state in update_states: + cb_mock.assert_any_await(state) + + # sleep based on the upgrade_time + sleep.assert_any_call(upgrade_time) diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index bf746f2b..790393e5 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type): prop = getattr(humidity, feature) assert isinstance(prop, type) - feat = humidity._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ba1b2293..ed691e66 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -1,13 +1,12 @@ from __future__ import annotations from itertools import chain -from typing import cast import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature -from kasa.smart.modules import LightEffectModule +from kasa import Device, Feature, Module +from kasa.smart.modules import LightEffect from kasa.tests.device_fixtures import parametrize light_effect = parametrize( @@ -18,10 +17,10 @@ light_effect = parametrize( @light_effect async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" - light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) - assert light_effect + light_effect = dev.modules.get(Module.LightEffect) + assert isinstance(light_effect, LightEffect) - feature = light_effect._module_features["light_effect"] + feature = dev.features["light_effect"] assert feature.type == Feature.Type.Choice call = mocker.spy(light_effect, "call") @@ -29,7 +28,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): assert feature.choices for effect in chain(reversed(feature.choices), feature.choices): await light_effect.set_effect(effect) - enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF + enable = effect != LightEffect.LIGHT_EFFECTS_OFF params: dict[str, bool | str] = {"enable": enable} if enable: params["id"] = light_effect._scenes_names_to_id[effect] diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index a7d20dac..c9685b9d 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) @@ -42,6 +42,6 @@ async def test_temperature_warning(dev): assert hasattr(temp_module, "temperature_warning") assert isinstance(temp_module.temperature_warning, bool) - feat = temp_module._module_features["temperature_warning"] + feat = dev.features["temperature_warning"] assert feat.value == temp_module.temperature_warning assert isinstance(feat.value, bool) diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 4154cbf8..16e01ed2 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index aa589e44..61536193 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type): prop = getattr(waterleak, prop_name) assert isinstance(prop, type) - feat = waterleak._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index acee8f74..2930db57 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,19 +7,18 @@ from voluptuous import ( Schema, ) -from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException +from kasa import Device, DeviceType, KasaException, LightPreset, Module from kasa.iot import IotBulb, IotDimmer -from kasa.smart import SmartDevice from .conftest import ( bulb, bulb_iot, color_bulb, color_bulb_iot, - dimmable, + dimmable_iot, handle_turn_on, non_color_bulb, - non_dimmable, + non_dimmable_iot, non_variable_temp, turn_on, variable_temp, @@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Bulb) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 assert 0 <= saturation <= 100 assert 0 <= brightness <= 100 - await dev.set_hsv(hue=1, saturation=1, value=1) + await light.set_hsv(hue=1, saturation=1, value=1) await dev.update() - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert hue == 1 assert saturation == 1 assert brightness == 1 @@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Bulb, turn_on): +async def test_invalid_hsv(dev: Device, turn_on): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] + await light.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) # type: ignore[arg-type] + await light.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) # type: ignore[arg-type] + await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Bulb) + light = dev.modules.get(Module.Light) + assert light assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv + assert dev.state_information["HSV"] == light.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Bulb): - assert not dev.is_color +async def test_hsv_on_non_color(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert not light.is_color with pytest.raises(KasaException): - await dev.set_hsv(0, 0, 0) + await light.set_hsv(0, 0, 0) with pytest.raises(KasaException): - print(dev.hsv) + print(light.hsv) @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Bulb) + light = dev.modules.get(Module.Light) + assert light assert "Color temperature" in dev.state_information - assert dev.state_information["Color temperature"] == dev.color_temp + assert dev.state_information["Color temperature"] == light.color_temp @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Bulb) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) + await light.set_color_temp(2700) await dev.update() - assert dev.color_temp == 2700 + assert light.color_temp == 2700 @variable_temp_iot @@ -166,36 +173,42 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): @variable_temp_smart -async def test_smart_temp_range(dev: SmartDevice): - assert dev.valid_temperature_range +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range @variable_temp -async def test_out_of_range_temperature(dev: Bulb): +async def test_out_of_range_temperature(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(ValueError): - await dev.set_color_temp(1000) + await light.set_color_temp(1000) with pytest.raises(ValueError): - await dev.set_color_temp(10000) + await light.set_color_temp(10000) @non_variable_temp -async def test_non_variable_temp(dev: Bulb): +async def test_non_variable_temp(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(KasaException): - await dev.set_color_temp(2700) + await light.set_color_temp(2700) with pytest.raises(KasaException): - print(dev.valid_temperature_range) + print(light.valid_temperature_range) with pytest.raises(KasaException): - print(dev.color_temp) + print(light.color_temp) -@dimmable +@dimmable_iot @turn_on -async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Bulb, IotDimmer)) +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) - assert dev.is_dimmable + assert dev._is_dimmable await dev.set_brightness(50) await dev.update() @@ -229,9 +242,9 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state.assert_called_with({"brightness": 10}, transition=1000) -@dimmable -async def test_invalid_brightness(dev: Bulb): - assert dev.is_dimmable +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): + assert dev._is_dimmable with pytest.raises(ValueError): await dev.set_brightness(110) @@ -240,9 +253,9 @@ async def test_invalid_brightness(dev: Bulb): await dev.set_brightness(-100) -@non_dimmable -async def test_non_dimmable(dev: Bulb): - assert not dev.is_dimmable +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): + assert not dev._is_dimmable with pytest.raises(KasaException): assert dev.brightness == 0 @@ -291,7 +304,7 @@ async def test_modify_preset(dev: IotBulb, mocker): "saturation": 0, "color_temp": 0, } - preset = BulbPreset(**data) + preset = LightPreset(**data) assert preset.index == 0 assert preset.brightness == 10 @@ -305,7 +318,7 @@ async def test_modify_preset(dev: IotBulb, mocker): with pytest.raises(KasaException): await dev.save_preset( - BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) @@ -314,11 +327,11 @@ async def test_modify_preset(dev: IotBulb, mocker): ("preset", "payload"), [ ( - BulbPreset(index=0, hue=0, brightness=1, saturation=0), + LightPreset(index=0, hue=0, brightness=1, saturation=0), {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], @@ -380,7 +393,7 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( @bulb -def test_device_type_bulb(dev): +def test_device_type_bulb(dev: Device): if dev.is_light_strip: pytest.skip("bulb has also lightstrips to test the api") assert dev.device_type == DeviceType.Bulb diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7addd434..422010ba 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -13,6 +13,7 @@ from kasa import ( DeviceError, EmeterStatus, KasaException, + Module, UnsupportedDeviceError, ) from kasa.cli import ( @@ -21,11 +22,15 @@ from kasa.cli import ( brightness, cli, cmd_command, + effect, emeter, + hsv, + led, raw_command, reboot, state, sysinfo, + temperature, toggle, update_credentials, wifi, @@ -34,7 +39,6 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice from .conftest import ( - device_iot, device_smart, get_device_for_fixture_protocol, handle_turn_on, @@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner): update.assert_called() -@device_iot -async def test_sysinfo(dev, runner): +async def test_sysinfo(dev: Device, runner): res = await runner.invoke(sysinfo, obj=dev) assert "System info" in res.output - assert dev.alias in res.output + assert dev.model in res.output @turn_on @@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner): assert dev.is_on != turn_on -@device_iot async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output @@ -308,15 +310,14 @@ async def test_emeter(dev: Device, mocker, runner): daily.assert_called_with(year=1900, month=12) -@device_iot -async def test_brightness(dev, runner): +async def test_brightness(dev: Device, runner): res = await runner.invoke(brightness, obj=dev) - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: assert "This device does not support brightness." in res.output return res = await runner.invoke(brightness, obj=dev) - assert f"Brightness: {dev.brightness}" in res.output + assert f"Brightness: {light.brightness}" in res.output res = await runner.invoke(brightness, ["12"], obj=dev) assert "Setting brightness" in res.output @@ -326,7 +327,110 @@ async def test_brightness(dev, runner): assert "Brightness: 12" in res.output -@device_iot +async def test_color_temperature(dev: Device, runner): + res = await runner.invoke(temperature, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + assert "Device does not support color temperature" in res.output + return + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {light.color_temp}" in res.output + valid_range = light.valid_temperature_range + assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output + + val = int((valid_range.min + valid_range.max) / 2) + res = await runner.invoke(temperature, [str(val)], obj=dev) + assert "Setting color temperature to " in res.output + await dev.update() + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {val}" in res.output + assert res.exit_code == 0 + + invalid_max = valid_range.max + 100 + # Lights that support the maximum range will not get past the click cli range check + # So can't be tested for the internal range check. + if invalid_max < 9000: + res = await runner.invoke(temperature, [str(invalid_max)], obj=dev) + assert res.exit_code == 1 + assert isinstance(res.exception, ValueError) + + res = await runner.invoke(temperature, [str(9100)], obj=dev) + assert res.exit_code == 2 + + +async def test_color_hsv(dev: Device, runner: CliRunner): + res = await runner.invoke(hsv, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_color: + assert "Device does not support colors" in res.output + return + + res = await runner.invoke(hsv, obj=dev) + assert f"Current HSV: {light.hsv}" in res.output + + res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev) + assert "Setting HSV: 180 50 50" in res.output + assert res.exit_code == 0 + await dev.update() + + res = await runner.invoke(hsv, ["180", "50"], obj=dev) + assert "Setting a color requires 3 values." in res.output + assert res.exit_code == 2 + + +async def test_light_effect(dev: Device, runner: CliRunner): + res = await runner.invoke(effect, obj=dev) + if not (light_effect := dev.modules.get(Module.LightEffect)): + assert "Device does not support effects" in res.output + return + + # Start off with a known state of off + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + + res = await runner.invoke(effect, obj=dev) + msg = ( + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}" + ) + assert msg in res.output + assert res.exit_code == 2 + + res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) + assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output + assert res.exit_code == 0 + await dev.update() + assert light_effect.effect == light_effect.effect_list[1] + + res = await runner.invoke(effect, ["foobar"], obj=dev) + assert f"Effect must be one of: {light_effect.effect_list}" in res.output + assert res.exit_code == 2 + + +async def test_led(dev: Device, runner: CliRunner): + res = await runner.invoke(led, obj=dev) + if not (led_module := dev.modules.get(Module.Led)): + assert "Device does not support led" in res.output + return + + res = await runner.invoke(led, obj=dev) + assert f"LED state: {led_module.led}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(led, ["on"], obj=dev) + assert "Turning led to True" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is True + + res = await runner.invoke(led, ["off"], obj=dev) + assert "Turning led to False" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is False + + async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) @@ -375,7 +479,6 @@ async def test_credentials(discovery_mock, mocker, runner): assert "Username:foo Password:bar\n" in res.output -@device_iot async def test_without_device_type(dev, mocker, runner): """Test connecting without the device type.""" discovery_mock = mocker.patch( @@ -737,7 +840,7 @@ async def test_feature_set(mocker, runner): dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) - led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") + led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) res = await runner.invoke( diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py new file mode 100644 index 00000000..b07d8d98 --- /dev/null +++ b/kasa/tests/test_common_modules.py @@ -0,0 +1,125 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module +from kasa.tests.device_fixtures import ( + dimmable_iot, + dimmer_iot, + lightstrip_iot, + parametrize, + parametrize_combine, + plug_iot, +) + +led_smart = parametrize( + "has led smart", component_filter="led", protocol_filter={"SMART"} +) +led = parametrize_combine([led_smart, plug_iot]) + +light_effect_smart = parametrize( + "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} +) +light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) + +dimmable_smart = parametrize( + "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} +) +dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) + + +@led +async def test_led_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + led_module = dev.modules.get(Module.Led) + assert led_module + feat = dev.features["led"] + + call = mocker.spy(led_module, "call") + await led_module.set_led(True) + assert call.call_count == 1 + await dev.update() + assert led_module.led is True + assert feat.value is True + + await led_module.set_led(False) + assert call.call_count == 2 + await dev.update() + assert led_module.led is False + assert feat.value is False + + await feat.set_value(True) + assert call.call_count == 3 + await dev.update() + assert feat.value is True + assert led_module.led is True + + +@light_effect +async def test_light_effect_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + light_effect_module = dev.modules[Module.LightEffect] + assert light_effect_module + feat = dev.features["light_effect"] + + call = mocker.spy(light_effect_module, "call") + effect_list = light_effect_module.effect_list + assert "Off" in effect_list + assert effect_list.index("Off") == 0 + assert len(effect_list) > 1 + assert effect_list == feat.choices + + assert light_effect_module.has_custom_effects is not None + + await light_effect_module.set_effect("Off") + assert call.call_count == 1 + await dev.update() + assert light_effect_module.effect == "Off" + assert feat.value == "Off" + + second_effect = effect_list[1] + await light_effect_module.set_effect(second_effect) + assert call.call_count == 2 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + last_effect = effect_list[len(effect_list) - 1] + await light_effect_module.set_effect(last_effect) + assert call.call_count == 3 + await dev.update() + assert light_effect_module.effect == last_effect + assert feat.value == last_effect + + # Test feature set + await feat.set_value(second_effect) + assert call.call_count == 4 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + with pytest.raises(ValueError): + await light_effect_module.set_effect("foobar") + assert call.call_count == 4 + + +@dimmable +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = dev.modules.get(Module.Light) + assert light + + # Test getting the value + feature = dev.features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(ValueError): + await light.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_brightness(feature.maximum_value + 10) diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d0ed0c71..6fd63d15 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch import pytest import kasa -from kasa import Credentials, Device, DeviceConfig +from kasa import Credentials, Device, DeviceConfig, DeviceType from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -25,6 +25,7 @@ def _get_subclasses(of_class): inspect.isclass(obj) and issubclass(obj, of_class) and module.__package__ != "kasa" + and module.__package__ != "kasa.interfaces" ): subclasses.add((module.__package__ + "." + name, obj)) return subclasses @@ -120,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class): with pytest.deprecated_call(match=msg): getattr(kasa, exceptions_class) getattr(kasa, use_class.__name__) + + +deprecated_is_device_type = { + "is_bulb": DeviceType.Bulb, + "is_plug": DeviceType.Plug, + "is_dimmer": DeviceType.Dimmer, + "is_light_strip": DeviceType.LightStrip, + "is_wallswitch": DeviceType.WallSwitch, + "is_strip": DeviceType.Strip, + "is_strip_socket": DeviceType.StripSocket, +} +deprecated_is_light_function_smart_module = { + "is_color": "Color", + "is_dimmable": "Brightness", + "is_variable_color_temp": "ColorTemperature", +} + + +def test_deprecated_attributes(dev: SmartDevice): + """Test deprecated attributes on all devices.""" + tested_keys = set() + + def _test_attr(attribute): + tested_keys.add(attribute) + msg = f"{attribute} is deprecated" + if module := Device._deprecated_attributes[attribute][0]: + msg += f", use: {module} in device.modules instead" + with pytest.deprecated_call(match=msg): + val = getattr(dev, attribute) + return val + + for attribute in deprecated_is_device_type: + val = _test_attr(attribute) + expected_val = dev.device_type == deprecated_is_device_type[attribute] + assert val == expected_val + + for attribute in deprecated_is_light_function_smart_module: + val = _test_attr(attribute) + if isinstance(dev, SmartDevice): + expected_val = ( + deprecated_is_light_function_smart_module[attribute] in dev.modules + ) + elif hasattr(dev, f"_{attribute}"): + expected_val = getattr(dev, f"_{attribute}") + else: + expected_val = False + assert val == expected_val + + assert len(tested_keys) == len(Device._deprecated_attributes) + untested_keys = [ + key for key in Device._deprecated_attributes if key not in tested_keys + ] + assert len(untested_keys) == 0 diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 6399ca4f..06150d39 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -3,10 +3,10 @@ import pytest from kasa import DeviceType from kasa.iot import IotDimmer -from .conftest import dimmer, handle_turn_on, turn_on +from .conftest import dimmer_iot, handle_turn_on, turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -22,7 +22,7 @@ async def test_set_brightness(dev, turn_on): assert dev.is_on == turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -44,7 +44,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): assert dev.brightness == 1 -@dimmer +@dimmer_iot async def test_set_brightness_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev): await dev.set_brightness(1, transition=invalid_transition) -@dimmer +@dimmer_iot async def test_turn_on_transition(dev, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness @@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker): assert dev.brightness == original_brightness -@dimmer +@dimmer_iot async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): ) -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -108,7 +108,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): assert dev.brightness == 99 -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -127,7 +127,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): ) -@dimmer +@dimmer_iot async def test_set_dimmer_transition_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev): await dev.set_dimmer_transition(1, invalid_transition) -@dimmer +@dimmer_iot def test_device_type_dimmer(dev): assert dev.device_type == DeviceType.Dimmer diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index eb039144..2dea2004 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption from .conftest import ( bulb_iot, - dimmer, - lightstrip, + dimmer_iot, + lightstrip_iot, new_discovery, plug_iot, strip_iot, @@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device): assert d.device_type == DeviceType.Strip -@dimmer +@dimmer_iot 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 +@lightstrip_iot async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 101a21c0..0fb7156d 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,5 +1,6 @@ import logging import sys +from unittest.mock import patch import pytest from pytest_mock import MockerFixture @@ -180,11 +181,10 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture): async def _test_features(dev): exceptions = [] - query = mocker.patch.object(dev.protocol, "query") for feat in dev.features.values(): - query.reset_mock() try: - await _test_feature(feat, query) + with patch.object(feat.device.protocol, "query") as query: + await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: pass diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index b4d56291..d5c76192 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -16,7 +16,7 @@ from voluptuous import ( Schema, ) -from kasa import KasaException +from kasa import KasaException, Module from kasa.iot import IotDevice from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on @@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "HS100(US)_2.0_1.5.6.json", "IOT" ) from kasa.iot.modules import Cloud - from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("Cloud") + module = dummy_device.modules.get("cloud") assert module assert module._device == dummy_device assert isinstance(module, Cloud) - module = dummy_device.get_module(Cloud) + module = dummy_device.modules.get(Module.IotCloud) assert module assert module._device == dummy_device assert isinstance(module, Cloud) # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module is None diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index fc987d2e..41fdcde1 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,29 +1,28 @@ import pytest from kasa import DeviceType -from kasa.exceptions import KasaException from kasa.iot import IotLightStrip -from .conftest import lightstrip +from .conftest import lightstrip_iot -@lightstrip +@lightstrip_iot 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 +@lightstrip_iot 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 +@lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(KasaException): + with pytest.raises(ValueError): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") @@ -31,7 +30,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): assert dev.effect["name"] == "Candy Cane" -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( dev: IotLightStrip, brightness, mocker @@ -49,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness( assert payload["brightness"] == brightness -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( dev: IotLightStrip, transition, mocker @@ -67,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition( assert payload["transition"] == transition -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list -@lightstrip +@lightstrip_iot def test_device_type_lightstrip(dev): assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index bb2f81bf..c4a4685a 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -9,12 +9,11 @@ from unittest.mock import patch import pytest from pytest_mock import MockerFixture -from kasa import KasaException +from kasa import KasaException, Module from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice from .conftest import ( - bulb_smart, device_smart, get_device_for_fixture_protocol, ) @@ -123,65 +122,42 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.iot.modules import AmbientLight - from kasa.smart.modules import CloudModule, FanModule + from kasa.smart.modules import Cloud # Modules on device - module = dummy_device.get_module("CloudModule") + module = dummy_device.modules.get("Cloud") assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) # Modules on child - module = dummy_device.get_module("FanModule") + module = dummy_device.modules.get("Fan") assert module assert module._device != dummy_device assert module._device._parent == dummy_device - module = dummy_device.get_module(FanModule) + module = dummy_device.modules.get(Module.Fan) assert module assert module._device != dummy_device assert module._device._parent == dummy_device # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(AmbientLight) + module = dummy_device.modules.get(Module.IotAmbientLight) assert module is None -@bulb_smart -async def test_smartdevice_brightness(dev: SmartDevice): - """Test brightness setter and getter.""" - assert isinstance(dev, SmartDevice) - assert "brightness" in dev._components - - # Test getting the value - feature = dev.features["brightness"] - assert feature.minimum_value == 1 - assert feature.maximum_value == 100 - - await dev.set_brightness(10) - await dev.update() - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness(feature.minimum_value - 10) - - with pytest.raises(ValueError): - await dev.set_brightness(feature.maximum_value + 10) - - @device_smart async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): """Test is_cloud_connected property."""