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."""