Merge remote-tracking branch 'upstream/master' into feat/device_update

This commit is contained in:
sdb9696 2024-05-14 18:35:23 +01:00
commit 06c5e55ac6
83 changed files with 3312 additions and 933 deletions

View File

@ -21,6 +21,11 @@ repos:
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: [types-click] additional_dependencies: [types-click]
exclude: |
(?x)^(
kasa/modulemapping\.py|
)$
- repo: https://github.com/PyCQA/doc8 - repo: https://github.com/PyCQA/doc8
rev: 'v1.1.1' rev: 'v1.1.1'

4
CONTRIBUTING.md Normal file
View File

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

View File

@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages:
## Contributing ## Contributing
Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. 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).
### 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 <address>
```
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.
## Supported devices ## 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).
<!--Do not edit text inside the SUPPORTED section below --> <!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
@ -242,7 +212,7 @@ The following devices have been tested and confirmed as working. If your device
- **Bulbs**: L510B, L510E, L530E - **Bulbs**: L510B, L510E, L530E
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Hubs**: H100 - **Hubs**: H100
- **Hub-Connected Devices<sup>\*\*\*</sup>**: T300, T310, T315 - **Hub-Connected Devices<sup>\*\*\*</sup>**: T110, T300, T310, T315
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
<sup>\*</sup>&nbsp;&nbsp; Model requires authentication<br> <sup>\*</sup>&nbsp;&nbsp; Model requires authentication<br>

View File

@ -137,6 +137,7 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
### Hub-Connected Devices ### Hub-Connected Devices
- **KE100** - **KE100**
- Hardware: 1.0 (EU) / Firmware: 2.4.0<sup>\*</sup>
- Hardware: 1.0 (EU) / Firmware: 2.8.0<sup>\*</sup> - Hardware: 1.0 (EU) / Firmware: 2.8.0<sup>\*</sup>
- Hardware: 1.0 (UK) / Firmware: 2.8.0<sup>\*</sup> - Hardware: 1.0 (UK) / Firmware: 2.8.0<sup>\*</sup>
@ -208,10 +209,13 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- **H100** - **H100**
- Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.5.10
- Hardware: 1.0 (EU) / Firmware: 1.5.5 - Hardware: 1.0 (EU) / Firmware: 1.5.5
### Hub-Connected Devices ### Hub-Connected Devices
- **T110**
- Hardware: 1.0 (EU) / Firmware: 1.8.0
- **T300** - **T300**
- Hardware: 1.0 (EU) / Firmware: 1.7.0 - Hardware: 1.0 (EU) / Firmware: 1.7.0
- **T310** - **T310**

View File

@ -19,7 +19,7 @@ app = typer.Typer()
def create_fixtures(dev: IotDevice, outputdir: Path): def create_fixtures(dev: IotDevice, outputdir: Path):
"""Iterate over supported modules and create version-specific fixture files.""" """Iterate over supported modules and create version-specific fixture files."""
for name, module in dev.modules.items(): for name, module in dev.modules.items():
module_dir = outputdir / name module_dir = outputdir / str(name)
if not module_dir.exists(): if not module_dir.exists():
module_dir.mkdir(exist_ok=True, parents=True) module_dir.mkdir(exist_ok=True, parents=True)

86
docs/source/contribute.md Normal file
View File

@ -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 <address>`.
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 <username> --password <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`.
```

View File

@ -10,6 +10,7 @@
discover discover
smartdevice smartdevice
design design
contribute
smartbulb smartbulb
smartplug smartplug
smartdimmer smartdimmer

View File

@ -16,7 +16,6 @@ from importlib.metadata import version
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from warnings import warn from warnings import warn
from kasa.bulb import Bulb
from kasa.credentials import Credentials from kasa.credentials import Credentials
from kasa.device import Device from kasa.device import Device
from kasa.device_type import DeviceType from kasa.device_type import DeviceType
@ -36,13 +35,12 @@ from kasa.exceptions import (
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.feature import Feature from kasa.feature import Feature
from kasa.firmware import Firmware, FirmwareUpdate from kasa.interfaces.light import Light, LightPreset
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import ( from kasa.iotprotocol import (
IotProtocol, IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401 _deprecated_TPLinkSmartHomeProtocol, # noqa: F401
) )
from kasa.plug import Plug from kasa.module import Module
from kasa.protocol import BaseProtocol from kasa.protocol import BaseProtocol
from kasa.smartprotocol import SmartProtocol from kasa.smartprotocol import SmartProtocol
@ -54,15 +52,16 @@ __all__ = [
"BaseProtocol", "BaseProtocol",
"IotProtocol", "IotProtocol",
"SmartProtocol", "SmartProtocol",
"BulbPreset", "LightPreset",
"TurnOnBehaviors", "TurnOnBehaviors",
"TurnOnBehavior", "TurnOnBehavior",
"DeviceType", "DeviceType",
"Feature", "Feature",
"EmeterStatus", "EmeterStatus",
"Device", "Device",
"Bulb", "Light",
"Plug", "Plug",
"Module",
"KasaException", "KasaException",
"AuthenticationError", "AuthenticationError",
"DeviceError", "DeviceError",
@ -73,8 +72,6 @@ __all__ = [
"ConnectionType", "ConnectionType",
"EncryptType", "EncryptType",
"DeviceFamilyType", "DeviceFamilyType",
"Firmware",
"FirmwareUpdate",
] ]
from . import iot from . import iot
@ -87,7 +84,7 @@ deprecated_smart_devices = {
"SmartLightStrip": iot.IotLightStrip, "SmartLightStrip": iot.IotLightStrip,
"SmartStrip": iot.IotStrip, "SmartStrip": iot.IotStrip,
"SmartDimmer": iot.IotDimmer, "SmartDimmer": iot.IotDimmer,
"SmartBulbPreset": BulbPreset, "SmartBulbPreset": LightPreset,
} }
deprecated_exceptions = { deprecated_exceptions = {
"SmartDeviceException": KasaException, "SmartDeviceException": KasaException,
@ -127,7 +124,7 @@ if TYPE_CHECKING:
SmartLightStrip = iot.IotLightStrip SmartLightStrip = iot.IotLightStrip
SmartStrip = iot.IotStrip SmartStrip = iot.IotStrip
SmartDimmer = iot.IotDimmer SmartDimmer = iot.IotDimmer
SmartBulbPreset = BulbPreset SmartBulbPreset = LightPreset
SmartDeviceException = KasaException SmartDeviceException = KasaException
UnsupportedDeviceException = UnsupportedDeviceError UnsupportedDeviceException = UnsupportedDeviceError

View File

@ -18,7 +18,6 @@ from pydantic.v1 import ValidationError
from kasa import ( from kasa import (
AuthenticationError, AuthenticationError,
Bulb,
ConnectionType, ConnectionType,
Credentials, Credentials,
Device, Device,
@ -28,6 +27,7 @@ from kasa import (
EncryptType, EncryptType,
Feature, Feature,
KasaException, KasaException,
Module,
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.discover import DiscoveryResult 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.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @pass_dev
async def brightness(dev: Bulb, brightness: int, transition: int): async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness.""" """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.") echo("This device does not support brightness.")
return return
if brightness is None: if brightness is None:
echo(f"Brightness: {dev.brightness}") echo(f"Brightness: {light.brightness}")
return dev.brightness return light.brightness
else: else:
echo(f"Setting brightness to {brightness}") echo(f"Setting brightness to {brightness}")
return await dev.set_brightness(brightness, transition=transition) return await light.set_brightness(brightness, transition=transition)
@cli.command() @cli.command()
@ -879,15 +879,15 @@ async def brightness(dev: Bulb, brightness: int, transition: int):
) )
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@pass_dev @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.""" """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") echo("Device does not support color temperature")
return return
if temperature is None: if temperature is None:
echo(f"Color temperature: {dev.color_temp}") echo(f"Color temperature: {light.color_temp}")
valid_temperature_range = dev.valid_temperature_range valid_temperature_range = light.valid_temperature_range
if valid_temperature_range != (0, 0): if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range)) echo("(min: {}, max: {})".format(*valid_temperature_range))
else: else:
@ -895,31 +895,34 @@ async def temperature(dev: Bulb, temperature: int, transition: int):
"Temperature range unknown, please open a github issue" "Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'" f" or a pull request for model '{dev.model}'"
) )
return dev.valid_temperature_range return light.valid_temperature_range
else: else:
echo(f"Setting color temperature to {temperature}") 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() @cli.command()
@click.argument("effect", type=click.STRING, default=None, required=False) @click.argument("effect", type=click.STRING, default=None, required=False)
@click.pass_context @click.pass_context
@pass_dev @pass_dev
async def effect(dev, ctx, effect): async def effect(dev: Device, ctx, effect):
"""Set an effect.""" """Set an effect."""
if not dev.has_effects: if not (light_effect := dev.modules.get(Module.LightEffect)):
echo("Device does not support effects") echo("Device does not support effects")
return return
if effect is None: if effect is None:
raise click.BadArgumentUsage( 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, ctx,
) )
if effect not in dev.effect_list: if effect not in light_effect.effect_list:
raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) raise click.BadArgumentUsage(
f"Effect must be one of: {light_effect.effect_list}", ctx
)
echo(f"Setting Effect: {effect}") echo(f"Setting Effect: {effect}")
return await dev.set_effect(effect) return await light_effect.set_effect(effect)
@cli.command() @cli.command()
@ -929,33 +932,36 @@ async def effect(dev, ctx, effect):
@click.option("--transition", type=int, required=False) @click.option("--transition", type=int, required=False)
@click.pass_context @click.pass_context
@pass_dev @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.""" """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") echo("Device does not support colors")
return return
if h is None or s is None or v is None: if h is None and s is None and v is None:
echo(f"Current HSV: {dev.hsv}") echo(f"Current HSV: {light.hsv}")
return dev.hsv return light.hsv
elif s is None or v is None: elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else: else:
echo(f"Setting HSV: {h} {s} {v}") 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() @cli.command()
@click.argument("state", type=bool, required=False) @click.argument("state", type=bool, required=False)
@pass_dev @pass_dev
async def led(dev, state): async def led(dev: Device, state):
"""Get or set (Plug's) led 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: if state is not None:
echo(f"Turning led to {state}") echo(f"Turning led to {state}")
return await dev.set_led(state) return await led.set_led(state)
else: else:
echo(f"LED state: {dev.led}") echo(f"LED state: {led.led}")
return dev.led return led.led
@cli.command() @cli.command()
@ -975,8 +981,8 @@ async def time(dev):
async def on(dev: Device, index: int, name: str, transition: int): async def on(dev: Device, index: int, name: str, transition: int):
"""Turn the device on.""" """Turn the device on."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.children:
echo("Index and name are only for power strips!") echo("Index and name are only for devices with children.")
return return
if index is not None: 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): async def off(dev: Device, index: int, name: str, transition: int):
"""Turn the device off.""" """Turn the device off."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.children:
echo("Index and name are only for power strips!") echo("Index and name are only for devices with children.")
return return
if index is not None: 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): async def toggle(dev: Device, index: int, name: str, transition: int):
"""Toggle the device on/off.""" """Toggle the device on/off."""
if index is not None or name is not None: if index is not None or name is not None:
if not dev.is_strip: if not dev.children:
echo("Index and name are only for power strips!") echo("Index and name are only for devices with children.")
return return
if index is not None: if index is not None:
@ -1266,7 +1272,11 @@ async def firmware(ctx: click.Context, dev: Device):
@click.pass_context @click.pass_context
async def firmware_info(ctx: click.Context, dev: Device): async def firmware_info(ctx: click.Context, dev: Device):
"""Return firmware information.""" """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: if res.update_available:
echo("[green bold]Update available![/green bold]") echo("[green bold]Update available![/green bold]")
echo(f"Current firmware: {res.current_version}") 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(f"Progress: {x}")
echo("Going to update %s", dev) 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__": if __name__ == "__main__":

View File

@ -6,7 +6,8 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime 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 .credentials import Credentials
from .device_type import DeviceType from .device_type import DeviceType
@ -14,12 +15,14 @@ from .deviceconfig import DeviceConfig
from .emeterstatus import EmeterStatus from .emeterstatus import EmeterStatus
from .exceptions import KasaException from .exceptions import KasaException
from .feature import Feature from .feature import Feature
from .firmware import Firmware
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .module import Module, ModuleT from .module import Module
from .protocol import BaseProtocol from .protocol import BaseProtocol
from .xortransport import XorTransport from .xortransport import XorTransport
if TYPE_CHECKING:
from .modulemapping import ModuleMapping
@dataclass @dataclass
class WifiNetwork: class WifiNetwork:
@ -114,21 +117,9 @@ class Device(ABC):
@property @property
@abstractmethod @abstractmethod
def modules(self) -> Mapping[str, Module]: def modules(self) -> ModuleMapping[Module]:
"""Return the device modules.""" """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 @property
@abstractmethod @abstractmethod
def is_on(self) -> bool: def is_on(self) -> bool:
@ -218,61 +209,6 @@ class Device(ABC):
def sys_info(self) -> dict[str, Any]: def sys_info(self) -> dict[str, Any]:
"""Returns the device info.""" """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: def get_plug_by_name(self, name: str) -> Device:
"""Return child device for the given name.""" """Return child device for the given name."""
for p in self.children: for p in self.children:
@ -289,11 +225,6 @@ class Device(ABC):
) )
return self.children[index] return self.children[index]
@property
@abstractmethod
def firmware(self) -> Firmware:
"""Return firmware."""
@property @property
@abstractmethod @abstractmethod
def time(self) -> datetime: def time(self) -> datetime:
@ -398,3 +329,53 @@ class Device(ABC):
if self._last_update is None: if self._last_update is None:
return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - update() needed>"
return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" 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}")

View File

@ -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",
]

View File

@ -4,10 +4,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod 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.""" """Interface for a Fan."""
@property @property

View File

@ -5,13 +5,25 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from typing import Any, Awaitable, Callable from typing import Callable, Coroutine
from ..module import Module
UpdateResult = bool UpdateResult = bool
class FirmwareDownloadState(ABC):
"""Download state."""
status: int
progress: int
reboot_time: int
upgrade_time: int
auto_upgrade: bool
@dataclass @dataclass
class FirmwareUpdate: class FirmwareUpdateInfo:
"""Update info status object.""" """Update info status object."""
update_available: bool | None = None update_available: bool | None = None
@ -21,21 +33,19 @@ class FirmwareUpdate:
release_notes: str | None = None release_notes: str | None = None
class Firmware(ABC): class Firmware(Module, ABC):
"""Interface to access firmware information and perform updates.""" """Interface to access firmware information and perform updates."""
@abstractmethod @abstractmethod
async def update_firmware( async def update_firmware(
self, *, progress_cb: Callable[[Any, Any], Awaitable] self, *, progress_cb: Callable[[FirmwareDownloadState], Coroutine] | None = None
) -> UpdateResult: ) -> UpdateResult:
"""Perform firmware update. """Perform firmware update.
This "blocks" until the update process has finished. This "blocks" until the update process has finished.
You can set *progress_cb* to get progress updates. You can set *progress_cb* to get progress updates.
""" """
raise NotImplementedError
@abstractmethod @abstractmethod
async def check_for_updates(self) -> FirmwareUpdate: async def check_for_updates(self) -> FirmwareUpdateInfo:
"""Return information about available updates.""" """Return firmware update information."""
raise NotImplementedError

38
kasa/interfaces/led.py Normal file
View File

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

View File

@ -7,7 +7,7 @@ from typing import NamedTuple, Optional
from pydantic.v1 import BaseModel from pydantic.v1 import BaseModel
from .device import Device from ..module import Module
class ColorTempRange(NamedTuple): class ColorTempRange(NamedTuple):
@ -25,8 +25,8 @@ class HSV(NamedTuple):
value: int value: int
class BulbPreset(BaseModel): class LightPreset(BaseModel):
"""Bulb configuration preset.""" """Light configuration preset."""
index: int index: int
brightness: int brightness: int
@ -42,23 +42,19 @@ class BulbPreset(BaseModel):
mode: Optional[int] # noqa: UP007 mode: Optional[int] # noqa: UP007
class Bulb(Device, ABC): class Light(Module, ABC):
"""Base class for TP-Link Bulb.""" """Base class for TP-Link Light."""
def _raise_for_invalid_brightness(self, value): @property
if not isinstance(value, int) or not (0 <= value <= 100): @abstractmethod
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") def is_dimmable(self) -> bool:
"""Whether the light supports brightness changes."""
@property @property
@abstractmethod @abstractmethod
def is_color(self) -> bool: def is_color(self) -> bool:
"""Whether the bulb supports color changes.""" """Whether the bulb supports color changes."""
@property
@abstractmethod
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
@property @property
@abstractmethod @abstractmethod
def is_variable_color_temp(self) -> bool: def is_variable_color_temp(self) -> bool:
@ -137,8 +133,3 @@ class Bulb(Device, ABC):
:param int brightness: brightness in percent :param int brightness: brightness in percent
:param int transition: transition in milliseconds. :param int transition: transition in milliseconds.
""" """
@property
@abstractmethod
def presets(self) -> list[BulbPreset]:
"""Return a list of available bulb setting presets."""

View File

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

View File

@ -9,13 +9,22 @@ from typing import Optional, cast
from pydantic.v1 import BaseModel, Field, root_validator from pydantic.v1 import BaseModel, Field, root_validator
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature from ..interfaces.light import HSV, ColorTempRange, LightPreset
from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update 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): class BehaviorMode(str, Enum):
@ -87,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class IotBulb(IotDevice, Bulb): class IotBulb(IotDevice):
r"""Representation of a TP-Link Smart Bulb. r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once. 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 configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets >>> 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: instance to :func:`save_preset` method:
>>> preset = bulb.presets[0] >>> preset = bulb.presets[0]
@ -198,64 +207,40 @@ class IotBulb(IotDevice, Bulb):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Bulb 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): async def _initialize_modules(self):
await super()._initialize_features() """Initialize modules not added in init."""
await super()._initialize_modules()
if bool(self.sys_info["is_dimmable"]): # pragma: no branch self.add_module(
self._add_feature( Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule")
Feature( )
device=self, self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule"))
id="brightness", self.add_module(
name="Brightness", Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
attribute_getter="brightness", )
attribute_setter="set_brightness", self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting"))
minimum_value=1, self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
maximum_value=100, self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
type=Feature.Type.Number, self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
category=Feature.Category.Primary, self.add_module(Module.Light, Light(self, "light"))
)
)
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,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def is_color(self) -> bool: def _is_color(self) -> bool:
"""Whether the bulb supports color changes.""" """Whether the bulb supports color changes."""
sys_info = self.sys_info sys_info = self.sys_info
return bool(sys_info["is_color"]) return bool(sys_info["is_color"])
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def is_dimmable(self) -> bool: def _is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes.""" """Whether the bulb supports brightness changes."""
sys_info = self.sys_info sys_info = self.sys_info
return bool(sys_info["is_dimmable"]) return bool(sys_info["is_dimmable"])
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def is_variable_color_temp(self) -> bool: def _is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes.""" """Whether the bulb supports color temperature changes."""
sys_info = self.sys_info sys_info = self.sys_info
return bool(sys_info["is_variable_color_temp"]) return bool(sys_info["is_variable_color_temp"])
@ -267,7 +252,7 @@ class IotBulb(IotDevice, Bulb):
:return: White temperature range in Kelvin (minimum, maximum) :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") raise KasaException("Color temperature not supported")
for model, temp_range in TPLINK_KELVIN.items(): for model, temp_range in TPLINK_KELVIN.items():
@ -367,7 +352,7 @@ class IotBulb(IotDevice, Bulb):
:return: hue, saturation and value (degrees, %, %) :return: hue, saturation and value (degrees, %, %)
""" """
if not self.is_color: if not self._is_color:
raise KasaException("Bulb does not support color.") raise KasaException("Bulb does not support color.")
light_state = cast(dict, self.light_state) 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 value: value in percentage [0, 100]
:param int transition: transition in milliseconds. :param int transition: transition in milliseconds.
""" """
if not self.is_color: if not self._is_color:
raise KasaException("Bulb does not support color.") raise KasaException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360): if not isinstance(hue, int) or not (0 <= hue <= 360):
@ -421,7 +406,7 @@ class IotBulb(IotDevice, Bulb):
@requires_update @requires_update
def color_temp(self) -> int: def color_temp(self) -> int:
"""Return color temperature of the device in kelvin.""" """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.") raise KasaException("Bulb does not support colortemp.")
light_state = self.light_state light_state = self.light_state
@ -436,7 +421,7 @@ class IotBulb(IotDevice, Bulb):
:param int temp: The new color temperature, in Kelvin :param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds. :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.") raise KasaException("Bulb does not support colortemp.")
valid_temperature_range = self.valid_temperature_range 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) 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 @property # type: ignore
@requires_update @requires_update
def brightness(self) -> int: def brightness(self) -> int:
"""Return the current brightness in percentage.""" """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.") raise KasaException("Bulb is not dimmable.")
light_state = self.light_state light_state = self.light_state
@ -472,7 +461,7 @@ class IotBulb(IotDevice, Bulb):
:param int brightness: brightness in percent :param int brightness: brightness in percent
:param int transition: transition in milliseconds. :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.") raise KasaException("Bulb is not dimmable.")
self._raise_for_invalid_brightness(brightness) self._raise_for_invalid_brightness(brightness)
@ -518,11 +507,11 @@ class IotBulb(IotDevice, Bulb):
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def presets(self) -> list[BulbPreset]: def presets(self) -> list[LightPreset]:
"""Return a list of available bulb setting presets.""" """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. """Save a setting preset.
You can either construct a preset object manually, or pass an existing one You can either construct a preset object manually, or pass an existing one

View File

@ -19,18 +19,18 @@ import functools
import inspect import inspect
import logging import logging
from datetime import datetime, timedelta 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 ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException from ..exceptions import KasaException
from ..feature import Feature from ..feature import Feature
from ..firmware import Firmware from ..module import Module
from ..module import ModuleT from ..modulemapping import ModuleMapping, ModuleName
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotmodule import IotModule from .iotmodule import IotModule
from .modules import Emeter, Time from .modules import Emeter
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -191,7 +191,7 @@ class IotDevice(Device):
self._supported_modules: dict[str, IotModule] | None = None self._supported_modules: dict[str, IotModule] | None = None
self._legacy_features: set[str] = set() self._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {} self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str, IotModule] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {}
@property @property
def children(self) -> Sequence[IotDevice]: def children(self) -> Sequence[IotDevice]:
@ -199,38 +199,20 @@ class IotDevice(Device):
return list(self._children.values()) return list(self._children.values())
@property @property
def modules(self) -> dict[str, IotModule]: def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules.""" """Return the device modules."""
if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._modules)
return self._modules return self._modules
@overload def add_module(self, name: str | ModuleName[Module], module: IotModule):
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):
"""Register a module.""" """Register a module."""
if name in self.modules: if name in self.modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name) _LOGGER.debug("Module %s already registered, ignoring..." % name)
return return
_LOGGER.debug("Adding module %s", module) _LOGGER.debug("Adding module %s", module)
self.modules[name] = module self._modules[name] = module
def _create_request( def _create_request(
self, target: str, cmd: str, arg: dict | None = None, child_ids=None self, target: str, cmd: str, arg: dict | None = None, child_ids=None
@ -292,11 +274,11 @@ class IotDevice(Device):
@property # type: ignore @property # type: ignore
@requires_update @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.""" """Return a set of modules supported by the device."""
# TODO: this should rather be called `features`, but we don't want to break # 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? # 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 @property # type: ignore
@requires_update @requires_update
@ -325,12 +307,19 @@ class IotDevice(Device):
self._last_update = response self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"]) 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: if not self._features:
await self._initialize_features() await self._initialize_features()
await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"]) 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): async def _initialize_features(self):
self._add_feature( self._add_feature(
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: async def _modular_update(self, req: dict) -> None:
"""Execute an update query.""" """Execute an update query."""
if self.has_emeter: if self.has_emeter:
_LOGGER.debug( _LOGGER.debug(
"The device has emeter, querying its information along sysinfo" "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, # TODO: perhaps modules should not have unsupported modules,
# making separate handling for this unnecessary # making separate handling for this unnecessary
if self._supported_modules is None: if self._supported_modules is None:
supported = {} supported = {}
for module in self.modules.values(): for module in self._modules.values():
if module.is_supported: if module.is_supported:
supported[module._module] = module supported[module._module] = module
for module_feat in module._module_features.values():
self._add_feature(module_feat)
self._supported_modules = supported self._supported_modules = supported
request_list = [] request_list = []
est_response_size = 1024 if "system" in req else 0 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: if not module.is_supported:
_LOGGER.debug("Module %s not supported, skipping" % module) _LOGGER.debug("Module %s not supported, skipping" % module)
continue continue
@ -454,27 +446,27 @@ class IotDevice(Device):
@requires_update @requires_update
def time(self) -> datetime: def time(self) -> datetime:
"""Return current time from the device.""" """Return current time from the device."""
return cast(Time, self.modules["time"]).time return self.modules[Module.IotTime].time
@property @property
@requires_update @requires_update
def timezone(self) -> dict: def timezone(self) -> dict:
"""Return the current timezone.""" """Return the current timezone."""
return cast(Time, self.modules["time"]).timezone return self.modules[Module.IotTime].timezone
async def get_time(self) -> datetime | None: async def get_time(self) -> datetime | None:
"""Return current time from the device, if available.""" """Return current time from the device, if available."""
_LOGGER.warning( _LOGGER.warning(
"Use `time` property instead, this call will be removed in the future." "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: async def get_timezone(self) -> dict:
"""Return timezone information.""" """Return timezone information."""
_LOGGER.warning( _LOGGER.warning(
"Use `timezone` property instead, this call will be removed in the future." "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 @property # type: ignore
@requires_update @requires_update
@ -555,26 +547,26 @@ class IotDevice(Device):
def emeter_realtime(self) -> EmeterStatus: def emeter_realtime(self) -> EmeterStatus:
"""Return current energy readings.""" """Return current energy readings."""
self._verify_emeter() 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: async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings.""" """Retrieve current energy readings."""
self._verify_emeter() self._verify_emeter()
return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime()) return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime())
@property @property
@requires_update @requires_update
def emeter_today(self) -> float | None: def emeter_today(self) -> float | None:
"""Return today's energy consumption in kWh.""" """Return today's energy consumption in kWh."""
self._verify_emeter() self._verify_emeter()
return cast(Emeter, self.modules["emeter"]).emeter_today return self.modules[Module.IotEmeter].emeter_today
@property @property
@requires_update @requires_update
def emeter_this_month(self) -> float | None: def emeter_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh.""" """Return this month's energy consumption in kWh."""
self._verify_emeter() 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( async def get_emeter_daily(
self, year: int | None = None, month: int | None = None, kwh: bool = True 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 :return: mapping of day of month to value
""" """
self._verify_emeter() 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 year=year, month=month, kwh=kwh
) )
@ -603,15 +595,13 @@ class IotDevice(Device):
:return: dict: mapping of month to value :return: dict: mapping of month to value
""" """
self._verify_emeter() self._verify_emeter()
return await cast(Emeter, self.modules["emeter"]).get_monthstat( return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh)
year=year, kwh=kwh
)
@requires_update @requires_update
async def erase_emeter_stats(self) -> dict: async def erase_emeter_stats(self) -> dict:
"""Erase energy meter statistics.""" """Erase energy meter statistics."""
self._verify_emeter() self._verify_emeter()
return await cast(Emeter, self.modules["emeter"]).erase_stats() return await self.modules[Module.IotEmeter].erase_stats()
@requires_update @requires_update
async def current_consumption(self) -> float: async def current_consumption(self) -> float:
@ -716,9 +706,3 @@ class IotDevice(Device):
This should only be used for debugging purposes. This should only be used for debugging purposes.
""" """
return self._last_update or self._discovery_info 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"])

View File

@ -7,11 +7,11 @@ from typing import Any
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import KasaException, requires_update from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug from .iotplug import IotPlug
from .modules import AmbientLight, Motion from .modules import AmbientLight, Light, Motion
class ButtonAction(Enum): class ButtonAction(Enum):
@ -79,29 +79,15 @@ class IotDimmer(IotPlug):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Dimmer 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 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 # 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(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.Light, Light(self, "light"))
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,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -110,7 +96,7 @@ class IotDimmer(IotPlug):
Will return a range between 0 - 100. Will return a range between 0 - 100.
""" """
if not self.is_dimmable: if not self._is_dimmable:
raise KasaException("Device is not dimmable.") raise KasaException("Device is not dimmable.")
sys_info = self.sys_info sys_info = self.sys_info
@ -123,7 +109,7 @@ class IotDimmer(IotPlug):
:param int transition: transition duration in milliseconds. :param int transition: transition duration in milliseconds.
Using a transition will cause the dimmer to turn on. 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.") raise KasaException("Device is not dimmable.")
if not isinstance(brightness, int): if not isinstance(brightness, int):
@ -232,7 +218,7 @@ class IotDimmer(IotPlug):
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def is_dimmable(self) -> bool: def _is_dimmable(self) -> bool:
"""Whether the switch supports brightness changes.""" """Whether the switch supports brightness changes."""
sys_info = self.sys_info sys_info = self.sys_info
return "brightness" in sys_info return "brightness" in sys_info

View File

@ -4,10 +4,12 @@ from __future__ import annotations
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .effects import EFFECT_NAMES_V1
from .iotbulb import IotBulb from .iotbulb import IotBulb
from .iotdevice import KasaException, requires_update from .iotdevice import KasaException, requires_update
from .modules.lighteffect import LightEffect
class IotLightStrip(IotBulb): class IotLightStrip(IotBulb):
@ -55,6 +57,14 @@ class IotLightStrip(IotBulb):
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.LightStrip 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 @property # type: ignore
@requires_update @requires_update
def length(self) -> int: def length(self) -> int:
@ -73,6 +83,8 @@ class IotLightStrip(IotBulb):
'id': '', 'id': '',
'name': ''} 'name': ''}
""" """
# LightEffectModule returns the current effect name
# so return the dict here for backwards compatibility
return self.sys_info["lighting_effect_state"] return self.sys_info["lighting_effect_state"]
@property # type: ignore @property # type: ignore
@ -83,6 +95,8 @@ class IotLightStrip(IotBulb):
Example: Example:
['Aurora', 'Bubbling Cauldron', ...] ['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 return EFFECT_NAMES_V1 if self.has_effects else None
@requires_update @requires_update
@ -105,15 +119,9 @@ class IotLightStrip(IotBulb):
:param int brightness: The wanted brightness :param int brightness: The wanted brightness
:param int transition: The wanted transition time :param int transition: The wanted transition time
""" """
if effect not in EFFECT_MAPPING_V1: await self.modules[Module.LightEffect].set_effect(
raise KasaException(f"The effect {effect} is not a built in effect.") effect, brightness=brightness, transition=transition
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)
@requires_update @requires_update
async def set_custom_effect( async def set_custom_effect(
@ -126,8 +134,4 @@ class IotLightStrip(IotBulb):
""" """
if not self.has_effects: if not self.has_effects:
raise KasaException("Bulb does not support effects.") raise KasaException("Bulb does not support effects.")
await self._query_helper( await self.modules[Module.LightEffect].set_custom_effect(effect_dict)
"smartlife.iot.lighting_effect",
"set_lighting_effect",
effect_dict,
)

View File

@ -43,13 +43,19 @@ class IotModule(Module):
@property @property
def data(self): def data(self):
"""Return the module specific raw data from the last update.""" """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( raise KasaException(
f"You need to call update() prior accessing module data" f"You need to call update() prior accessing module data"
f" for '{self._module}'" f" for '{self._module}'"
) )
return self._device._last_update[self._module] return dev._last_update[self._module]
@property @property
def is_supported(self) -> bool: def is_supported(self) -> bool:

View File

@ -6,10 +6,10 @@ import logging
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import IotDevice, requires_update 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__) _LOGGER = logging.getLogger(__name__)
@ -53,26 +53,16 @@ class IotPlug(IotDevice):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Plug 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): async def _initialize_modules(self):
await super()._initialize_features() """Initialize modules."""
await super()._initialize_modules()
self._add_feature( self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
Feature( self.add_module(Module.IotUsage, Usage(self, "schedule"))
device=self, self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
id="led", self.add_module(Module.IotTime, Time(self, "time"))
name="LED", self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
icon="mdi:led-{state}", self.add_module(Module.Led, Led(self, "system"))
attribute_getter="led",
attribute_setter="set_led",
type=Feature.Type.Switch,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -93,14 +83,11 @@ class IotPlug(IotDevice):
@requires_update @requires_update
def led(self) -> bool: def led(self) -> bool:
"""Return the state of the led.""" """Return the state of the led."""
sys_info = self.sys_info return self.modules[Module.Led].led
return bool(1 - sys_info["led_off"])
async def set_led(self, state: bool): async def set_led(self, state: bool):
"""Set the state of the led (night mode).""" """Set the state of the led (night mode)."""
return await self._query_helper( return await self.modules[Module.Led].set_led(state)
"system", "set_led_off", {"off": int(not state)}
)
class IotWallSwitch(IotPlug): class IotWallSwitch(IotPlug):

View File

@ -10,6 +10,7 @@ from typing import Any
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException from ..exceptions import KasaException
from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import ( from .iotdevice import (
EmeterStatus, EmeterStatus,
@ -95,11 +96,11 @@ class IotStrip(IotDevice):
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self.emeter_type = "emeter" self.emeter_type = "emeter"
self._device_type = DeviceType.Strip self._device_type = DeviceType.Strip
self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module("schedule", Schedule(self, "schedule")) self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module("usage", Usage(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module("time", Time(self, "time")) self.add_module(Module.IotTime, Time(self, "time"))
self.add_module("countdown", Countdown(self, "countdown")) self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -253,8 +254,11 @@ class IotStripPlug(IotPlug):
self._last_update = parent._last_update self._last_update = parent._last_update
self._set_sys_info(parent.sys_info) self._set_sys_info(parent.sys_info)
self._device_type = DeviceType.StripSocket self._device_type = DeviceType.StripSocket
self._modules = {}
self.protocol = parent.protocol # Must use the same connection as the parent 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")) self.add_module("time", Time(self, "time"))
async def update(self, update_children: bool = True): async def update(self, update_children: bool = True):

View File

@ -5,6 +5,9 @@ from .antitheft import Antitheft
from .cloud import Cloud from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .emeter import Emeter from .emeter import Emeter
from .led import Led
from .light import Light
from .lighteffect import LightEffect
from .motion import Motion from .motion import Motion
from .rulemodule import Rule, RuleModule from .rulemodule import Rule, RuleModule
from .schedule import Schedule from .schedule import Schedule
@ -17,6 +20,9 @@ __all__ = [
"Cloud", "Cloud",
"Countdown", "Countdown",
"Emeter", "Emeter",
"Led",
"Light",
"LightEffect",
"Motion", "Motion",
"Rule", "Rule",
"RuleModule", "RuleModule",

View File

@ -4,19 +4,22 @@ from __future__ import annotations
import logging import logging
from datetime import date from datetime import date
from typing import Optional from typing import Callable, Coroutine, Optional
from pydantic.v1 import BaseModel, Field, validator from pydantic.v1 import BaseModel, Field, validator
from ...feature import Feature from ...feature import Feature
from ...firmware import ( from ...interfaces.firmware import (
Firmware, Firmware,
UpdateResult, UpdateResult,
) )
from ...firmware import ( from ...interfaces.firmware import (
FirmwareUpdate as FirmwareUpdateInterface, FirmwareDownloadState as FirmwareDownloadStateInterface,
) )
from ..iotmodule import IotModule, merge from ...interfaces.firmware import (
FirmwareUpdateInfo as FirmwareUpdateInfoInterface,
)
from ..iotmodule import IotModule
_LOGGER = logging.getLogger(__name__) _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 # TODO: this is problematic, as it will fail the whole query on some
# devices if they are not connected to the internet # 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 return req
@ -130,7 +136,12 @@ class Cloud(IotModule, Firmware):
"""Disconnect from the cloud.""" """Disconnect from the cloud."""
return await self.call("unbind") 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.""" """Perform firmware update."""
raise NotImplementedError raise NotImplementedError
i = 0 i = 0
@ -144,11 +155,16 @@ class Cloud(IotModule, Firmware):
return UpdateResult("") 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.""" """Return firmware update information."""
fw = await self.get_firmware_update() fw = await self.get_firmware_update()
return FirmwareUpdateInterface( return FirmwareUpdateInfoInterface(
update_available=fw.update_available, update_available=fw.update_available,
current_version=self._device.hw_info.get("sw_ver"), current_version=self._device.hw_info.get("sw_ver"),
available_version=fw.version, available_version=fw.version,

32
kasa/iot/modules/led.py Normal file
View File

@ -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)})

200
kasa/iot/modules/light.py Normal file
View File

@ -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
)

View File

@ -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 {}

View File

@ -6,14 +6,19 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Final,
TypeVar, TypeVar,
) )
from .exceptions import KasaException from .exceptions import KasaException
from .feature import Feature from .feature import Feature
from .modulemapping import ModuleName
if TYPE_CHECKING: if TYPE_CHECKING:
from . import interfaces
from .device import Device from .device import Device
from .iot import modules as iot
from .smart import modules as smart
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,6 +32,59 @@ class Module(ABC):
executed during the regular update cycle. 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): def __init__(self, device: Device, module: str):
self._device = device self._device = device
self._module = module self._module = module

25
kasa/modulemapping.py Normal file
View File

@ -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

96
kasa/modulemapping.pyi Normal file
View File

@ -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

View File

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

View File

@ -1,49 +1,53 @@
"""Modules for SMART devices.""" """Modules for SMART devices."""
from .alarmmodule import AlarmModule from .alarm import Alarm
from .autooffmodule import AutoOffModule from .autooff import AutoOff
from .battery import BatterySensor from .batterysensor import BatterySensor
from .brightness import Brightness from .brightness import Brightness
from .childdevicemodule import ChildDeviceModule from .childdevice import ChildDevice
from .cloudmodule import CloudModule from .cloud import Cloud
from .colormodule import ColorModule from .color import Color
from .colortemp import ColorTemperatureModule from .colortemperature import ColorTemperature
from .contactsensor import ContactSensor
from .devicemodule import DeviceModule from .devicemodule import DeviceModule
from .energymodule import EnergyModule from .energy import Energy
from .fanmodule import FanModule from .fan import Fan
from .firmware import Firmware from .firmware import Firmware
from .frostprotection import FrostProtectionModule from .frostprotection import FrostProtection
from .humidity import HumiditySensor from .humiditysensor import HumiditySensor
from .ledmodule import LedModule from .led import Led
from .lighteffectmodule import LightEffectModule from .light import Light
from .lighttransitionmodule import LightTransitionModule from .lighteffect import LightEffect
from .reportmodule import ReportModule from .lighttransition import LightTransition
from .temperature import TemperatureSensor from .reportmode import ReportMode
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
from .timemodule import TimeModule from .temperaturesensor import TemperatureSensor
from .waterleak import WaterleakSensor from .time import Time
from .waterleaksensor import WaterleakSensor
__all__ = [ __all__ = [
"AlarmModule", "Alarm",
"TimeModule", "Time",
"EnergyModule", "Energy",
"DeviceModule", "DeviceModule",
"ChildDeviceModule", "ChildDevice",
"BatterySensor", "BatterySensor",
"HumiditySensor", "HumiditySensor",
"TemperatureSensor", "TemperatureSensor",
"TemperatureControl", "TemperatureControl",
"ReportModule", "ReportMode",
"AutoOffModule", "AutoOff",
"LedModule", "Led",
"Brightness", "Brightness",
"FanModule", "Fan",
"Firmware", "Firmware",
"CloudModule", "Cloud",
"LightEffectModule", "Light",
"LightTransitionModule", "LightEffect",
"ColorTemperatureModule", "LightTransition",
"ColorModule", "ColorTemperature",
"Color",
"WaterleakSensor", "WaterleakSensor",
"FrostProtectionModule", "ContactSensor",
"FrostProtection",
] ]

View File

@ -6,7 +6,7 @@ from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
class AlarmModule(SmartModule): class Alarm(SmartModule):
"""Implementation of alarm module.""" """Implementation of alarm module."""
REQUIRED_COMPONENT = "alarm" REQUIRED_COMPONENT = "alarm"

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class AutoOffModule(SmartModule): class AutoOff(SmartModule):
"""Implementation of auto off module.""" """Implementation of auto off module."""
REQUIRED_COMPONENT = "auto_off" REQUIRED_COMPONENT = "auto_off"

View File

@ -2,14 +2,9 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class BatterySensor(SmartModule): class BatterySensor(SmartModule):
"""Implementation of battery module.""" """Implementation of battery module."""
@ -17,23 +12,11 @@ class BatterySensor(SmartModule):
REQUIRED_COMPONENT = "battery_detect" REQUIRED_COMPONENT = "battery_detect"
QUERY_GETTER_NAME = "get_battery_detect_info" QUERY_GETTER_NAME = "get_battery_detect_info"
def __init__(self, device: SmartDevice, module: str): def _initialize_features(self):
super().__init__(device, module) """Initialize features."""
self._add_feature( self._add_feature(
Feature( Feature(
device, self._device,
"battery_level",
"Battery level",
container=self,
attribute_getter="battery",
icon="mdi:battery",
unit="%",
category=Feature.Category.Info,
)
)
self._add_feature(
Feature(
device,
"battery_low", "battery_low",
"Battery low", "Battery low",
container=self, 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 @property
def battery(self): def battery(self):
"""Return battery level.""" """Return battery level."""

View File

@ -2,16 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: BRIGHTNESS_MIN = 0
from ..smartdevice import SmartDevice
BRIGHTNESS_MIN = 1
BRIGHTNESS_MAX = 100 BRIGHTNESS_MAX = 100
@ -20,8 +14,11 @@ class Brightness(SmartModule):
REQUIRED_COMPONENT = "brightness" REQUIRED_COMPONENT = "brightness"
def __init__(self, device: SmartDevice, module: str): def _initialize_features(self):
super().__init__(device, module) """Initialize features."""
super()._initialize_features()
device = self._device
self._add_feature( self._add_feature(
Feature( Feature(
device, device,
@ -47,8 +44,11 @@ class Brightness(SmartModule):
"""Return current brightness.""" """Return current brightness."""
return self.data["brightness"] return self.data["brightness"]
async def set_brightness(self, brightness: int): async def set_brightness(self, brightness: int, *, transition: int | None = None):
"""Set the brightness.""" """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 ( if not isinstance(brightness, int) or not (
BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX
): ):
@ -57,6 +57,8 @@ class Brightness(SmartModule):
f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" 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}) return await self.call("set_device_info", {"brightness": brightness})
async def _check_supported(self): async def _check_supported(self):

View File

@ -3,7 +3,7 @@
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
class ChildDeviceModule(SmartModule): class ChildDevice(SmartModule):
"""Implementation for child devices.""" """Implementation for child devices."""
REQUIRED_COMPONENT = "child_device" REQUIRED_COMPONENT = "child_device"

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class CloudModule(SmartModule): class Cloud(SmartModule):
"""Implementation of cloud module.""" """Implementation of cloud module."""
QUERY_GETTER_NAME = "get_connect_cloud_state" QUERY_GETTER_NAME = "get_connect_cloud_state"

View File

@ -4,15 +4,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...bulb import HSV
from ...feature import Feature from ...feature import Feature
from ...interfaces.light import HSV
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class ColorModule(SmartModule): class Color(SmartModule):
"""Implementation of color module.""" """Implementation of color module."""
REQUIRED_COMPONENT = "color" REQUIRED_COMPONENT = "color"

View File

@ -5,8 +5,8 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...bulb import ColorTempRange
from ...feature import Feature from ...feature import Feature
from ...interfaces.light import ColorTempRange
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_TEMP_RANGE = [2500, 6500] DEFAULT_TEMP_RANGE = [2500, 6500]
class ColorTemperatureModule(SmartModule): class ColorTemperature(SmartModule):
"""Implementation of color temp module.""" """Implementation of color temp module."""
REQUIRED_COMPONENT = "color_temperature" REQUIRED_COMPONENT = "color_temperature"

View File

@ -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"]

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class EnergyModule(SmartModule): class Energy(SmartModule):
"""Implementation of energy monitoring module.""" """Implementation of energy monitoring module."""
REQUIRED_COMPONENT = "energy_monitoring" REQUIRED_COMPONENT = "energy_monitoring"

View File

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class FanModule(SmartModule): class Fan(SmartModule):
"""Implementation of fan_control module.""" """Implementation of fan_control module."""
REQUIRED_COMPONENT = "fan_control" REQUIRED_COMPONENT = "fan_control"

View File

@ -5,10 +5,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import date 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 # When support for cpython older than 3.11 is dropped
# async_timeout can be replaced with asyncio.timeout # async_timeout can be replaced with asyncio.timeout
from async_timeout import timeout as 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 ...exceptions import SmartErrorCode
from ...feature import Feature from ...feature import Feature
from ...firmware import Firmware as FirmwareInterface from ...interfaces import Firmware as FirmwareInterface
from ...firmware import FirmwareUpdate as FirmwareUpdateInterface from ...interfaces.firmware import (
from ...firmware import UpdateResult FirmwareDownloadState as FirmwareDownloadStateInterface,
)
from ...interfaces.firmware import FirmwareUpdateInfo as FirmwareUpdateInfoInterface
from ...interfaces.firmware import UpdateResult
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
@ -28,7 +29,20 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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.""" """Update info status object."""
status: int = Field(alias="type") status: int = Field(alias="type")
@ -91,7 +105,7 @@ class Firmware(SmartModule, FirmwareInterface):
name="Current firmware version", name="Current firmware version",
container=self, container=self,
attribute_getter="current_firmware", attribute_getter="current_firmware",
category=Feature.Category.Info, category=Feature.Category.Debug,
) )
) )
self._add_feature( self._add_feature(
@ -101,7 +115,7 @@ class Firmware(SmartModule, FirmwareInterface):
name="Available firmware version", name="Available firmware version",
container=self, container=self,
attribute_getter="latest_firmware", 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 fw = self.data.get("get_latest_fw") or self.data
if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode):
# Error in response, probably disconnected from the cloud. # 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 @property
def update_available(self) -> bool | None: def update_available(self) -> bool | None:
@ -139,31 +153,60 @@ class Firmware(SmartModule, FirmwareInterface):
return None return None
return self.firmware_update_info.update_available return self.firmware_update_info.update_available
async def get_update_state(self): async def get_update_state(self) -> DownloadState:
"""Return update state.""" """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.""" """Update the device firmware."""
current_fw = self.current_firmware current_fw = self.current_firmware
_LOGGER.debug( _LOGGER.info(
"Going to upgrade from %s to %s", "Going to upgrade from %s to %s",
current_fw, current_fw,
self.firmware_update_info.version, self.firmware_update_info.version,
) )
resp = await self.call("fw_download") await self.call("fw_download")
_LOGGER.debug("Update request response: %s", resp)
# TODO: read timeout from get_auto_update_info or from get_fw_download_state? # TODO: read timeout from get_auto_update_info or from get_fw_download_state?
async with asyncio_timeout(60 * 5): async with asyncio_timeout(60 * 5):
while True: while True:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
state = await self.get_update_state() try:
_LOGGER.debug("Update state: %s" % state) state = await self.get_update_state()
# TODO: this could await a given callable for progress 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.debug("Update state: %s" % state)
_LOGGER.info("Updated to %s", self.firmware_update_info.version) 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 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 @property
def auto_update_enabled(self): def auto_update_enabled(self):
@ -178,16 +221,21 @@ class Firmware(SmartModule, FirmwareInterface):
data = {**self.data["get_auto_update_info"], "enable": enabled} data = {**self.data["get_auto_update_info"], "enable": enabled}
await self.call("set_auto_update_info", data) 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.""" """Update the firmware."""
# TODO: implement, this is part of the common firmware API # TODO: implement, this is part of the common firmware API
raise NotImplementedError raise NotImplementedError
async def check_for_updates(self) -> FirmwareUpdateInterface: async def check_for_updates(self) -> FirmwareUpdateInfoInterface:
"""Return firmware update information.""" """Return firmware update information."""
# TODO: naming of the common firmware API methods # TODO: naming of the common firmware API methods
info = self.firmware_update_info info = self.firmware_update_info
return FirmwareUpdateInterface( return FirmwareUpdateInfoInterface(
current_version=self.current_firmware, current_version=self.current_firmware,
update_available=info.update_available, update_available=info.update_available,
available_version=info.version, available_version=info.version,

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class FrostProtectionModule(SmartModule): class FrostProtection(SmartModule):
"""Implementation for frost protection module. """Implementation for frost protection module.
This basically turns the thermostat on and off. This basically turns the thermostat on and off.

View File

@ -2,37 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from ...interfaces.led import Led as LedInterface
from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class Led(SmartModule, LedInterface):
class LedModule(SmartModule):
"""Implementation of led controls.""" """Implementation of led controls."""
REQUIRED_COMPONENT = "led" REQUIRED_COMPONENT = "led"
QUERY_GETTER_NAME = "get_led_info" 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: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {self.QUERY_GETTER_NAME: {"led_rule": None}} 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. This should probably be a select with always/never/nightmode.
""" """
rule = "always" if enable else "never" 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 @property
def night_mode_settings(self): def night_mode_settings(self):

126
kasa/smart/modules/light.py Normal file
View File

@ -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

View File

@ -6,14 +6,14 @@ import base64
import copy import copy
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ...feature import Feature from ...interfaces.lighteffect import LightEffect as LightEffectInterface
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class LightEffectModule(SmartModule): class LightEffect(SmartModule, LightEffectInterface):
"""Implementation of dynamic light effects.""" """Implementation of dynamic light effects."""
REQUIRED_COMPONENT = "light_effect" REQUIRED_COMPONENT = "light_effect"
@ -22,29 +22,11 @@ class LightEffectModule(SmartModule):
"L1": "Party", "L1": "Party",
"L2": "Relax", "L2": "Relax",
} }
LIGHT_EFFECTS_OFF = "Off"
def __init__(self, device: SmartDevice, module: str): def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module) super().__init__(device, module)
self._scenes_names_to_id: dict[str, str] = {} 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]]: def _initialize_effects(self) -> dict[str, dict[str, Any]]:
"""Return built-in effects.""" """Return built-in effects."""
# Copy the effects so scene name updates do not update the underlying dict. # Copy the effects so scene name updates do not update the underlying dict.
@ -64,7 +46,7 @@ class LightEffectModule(SmartModule):
return effects return effects
@property @property
def effect_list(self) -> list[str] | None: def effect_list(self) -> list[str]:
"""Return built-in effects list. """Return built-in effects list.
Example: Example:
@ -90,6 +72,9 @@ class LightEffectModule(SmartModule):
async def set_effect( async def set_effect(
self, self,
effect: str, effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None: ) -> None:
"""Set an effect for the device. """Set an effect for the device.
@ -108,6 +93,24 @@ class LightEffectModule(SmartModule):
params["id"] = effect_id params["id"] = effect_id
return await self.call("set_dynamic_light_effect_rule_enable", params) 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: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {self.QUERY_GETTER_NAME: {"start_index": 0}} return {self.QUERY_GETTER_NAME: {"start_index": 0}}

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class LightTransitionModule(SmartModule): class LightTransition(SmartModule):
"""Implementation of gradual on/off.""" """Implementation of gradual on/off."""
REQUIRED_COMPONENT = "on_off_gradually" REQUIRED_COMPONENT = "on_off_gradually"

View File

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class ReportModule(SmartModule): class ReportMode(SmartModule):
"""Implementation of report module.""" """Implementation of report module."""
REQUIRED_COMPONENT = "report_mode" REQUIRED_COMPONENT = "report_mode"

View File

@ -13,7 +13,7 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class TimeModule(SmartModule): class Time(SmartModule):
"""Implementation of device_local_time.""" """Implementation of device_local_time."""
REQUIRED_COMPONENT = "time" REQUIRED_COMPONENT = "time"

View File

@ -49,6 +49,7 @@ class SmartChildDevice(SmartDevice):
"""Return child device type.""" """Return child device type."""
child_device_map = { child_device_map = {
"plug.powerstrip.sub-plug": DeviceType.Plug, "plug.powerstrip.sub-plug": DeviceType.Plug,
"subg.trigger.contact-sensor": DeviceType.Sensor,
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
"subg.trigger.water-leak-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor,
"kasa.switch.outlet.sub-fan": DeviceType.Fan, "kasa.switch.outlet.sub-fan": DeviceType.Fan,

View File

@ -5,32 +5,24 @@ from __future__ import annotations
import base64 import base64
import logging import logging
from datetime import datetime, timedelta 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 ..aestransport import AesTransport
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device import Device, WifiNetwork from ..device import Device, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..fan import Fan
from ..feature import Feature from ..feature import Feature
from ..firmware import Firmware from ..module import Module
from ..module import ModuleT from ..modulemapping import ModuleMapping, ModuleName
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
from .modules import ( from .modules import (
Brightness, Cloud,
CloudModule,
ColorModule,
ColorTemperatureModule,
DeviceModule, DeviceModule,
EnergyModule, Firmware,
FanModule, Light,
TimeModule, Time,
)
from .modules import (
Firmware as FirmwareModule,
) )
from .smartmodule import SmartModule 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. # 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 # This list should be updated when creating new modules that could have the
# same issue, homekit perhaps? # 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 # Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order. # 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.""" """Base class to represent a SMART protocol based device."""
def __init__( def __init__(
@ -64,7 +56,7 @@ class SmartDevice(Bulb, Fan, Device):
self._components_raw: dict[str, Any] | None = None self._components_raw: dict[str, Any] | None = None
self._components: dict[str, int] = {} self._components: dict[str, int] = {}
self._state_information: dict[str, Any] = {} self._state_information: dict[str, Any] = {}
self._modules: dict[str, SmartModule] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {}
self._exposes_child_modules = False self._exposes_child_modules = False
self._parent: SmartDevice | None = None self._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {} self._children: Mapping[str, SmartDevice] = {}
@ -105,8 +97,20 @@ class SmartDevice(Bulb, Fan, Device):
return list(self._children.values()) return list(self._children.values())
@property @property
def modules(self) -> dict[str, SmartModule]: def modules(self) -> ModuleMapping[SmartModule]:
"""Return the device modules.""" """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 return self._modules
def _try_get_response(self, responses: dict, request: str, default=None) -> dict: 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 skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES
) or mod.__name__ in child_modules_to_skip: ) or mod.__name__ in child_modules_to_skip:
continue 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( _LOGGER.debug(
"Found required %s, adding %s to modules.", "Found required %s, adding %s to modules.",
mod.REQUIRED_COMPONENT, mod.REQUIRED_COMPONENT,
@ -223,6 +230,13 @@ class SmartDevice(Bulb, Fan, Device):
if await module._check_supported(): if await module._check_supported():
self._modules[module.name] = module 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): async def _initialize_features(self):
"""Initialize device features.""" """Initialize device features."""
self._add_feature( self._add_feature(
@ -310,41 +324,20 @@ class SmartDevice(Bulb, Fan, Device):
) )
) )
for module in self._modules.values(): for module in self.modules.values():
module._initialize_features() # 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(): for feat in module._module_features.values():
self._add_feature(feat) 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 @property
def is_cloud_connected(self): def is_cloud_connected(self) -> bool:
"""Returns if the device is connected to the cloud.""" """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 False
return self.modules["CloudModule"].is_connected return self.modules[Module.Cloud].is_connected
@property @property
def sys_info(self) -> dict[str, Any]: def sys_info(self) -> dict[str, Any]:
@ -368,10 +361,10 @@ class SmartDevice(Bulb, Fan, Device):
def time(self) -> datetime: def time(self) -> datetime:
"""Return the time.""" """Return the time."""
# TODO: Default to parent's time module for child devices # TODO: Default to parent's time module for child devices
if self._parent and "TimeModule" in self.modules: if self._parent and Module.Time in self.modules:
_timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 _timemod = self._parent.modules[Module.Time]
else: else:
_timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 _timemod = self.modules[Module.Time]
return _timemod.time return _timemod.time
@ -448,12 +441,7 @@ class SmartDevice(Bulb, Fan, Device):
@property @property
def has_emeter(self) -> bool: def has_emeter(self) -> bool:
"""Return if the device has emeter.""" """Return if the device has emeter."""
return "EnergyModule" in self.modules return Module.Energy in self.modules
@property
def is_dimmer(self) -> bool:
"""Whether the device acts as a dimmer."""
return self.is_dimmable
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -490,19 +478,19 @@ class SmartDevice(Bulb, Fan, Device):
@property @property
def emeter_realtime(self) -> EmeterStatus: def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status.""" """Get the emeter status."""
energy = cast(EnergyModule, self.modules["EnergyModule"]) energy = self.modules[Module.Energy]
return energy.emeter_realtime return energy.emeter_realtime
@property @property
def emeter_this_month(self) -> float | None: def emeter_this_month(self) -> float | None:
"""Get the emeter value for this month.""" """Get the emeter value for this month."""
energy = cast(EnergyModule, self.modules["EnergyModule"]) energy = self.modules[Module.Energy]
return energy.emeter_this_month return energy.emeter_this_month
@property @property
def emeter_today(self) -> float | None: def emeter_today(self) -> float | None:
"""Get the emeter value for today.""" """Get the emeter value for today."""
energy = cast(EnergyModule, self.modules["EnergyModule"]) energy = self.modules[Module.Energy]
return energy.emeter_today return energy.emeter_today
@property @property
@ -514,8 +502,7 @@ class SmartDevice(Bulb, Fan, Device):
): ):
return None return None
on_time = cast(float, on_time) on_time = cast(float, on_time)
if (timemod := self.modules.get("TimeModule")) is not None: if (timemod := self.modules.get(Module.Time)) is not None:
timemod = cast(TimeModule, timemod) # noqa: F405
return timemod.time - timedelta(seconds=on_time) return timemod.time - timedelta(seconds=on_time)
else: # We have no device time, use current local time. else: # We have no device time, use current local time.
return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time)
@ -628,12 +615,6 @@ class SmartDevice(Bulb, Fan, Device):
return self._device_type 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 @staticmethod
def _get_device_type_from_components( def _get_device_type_from_components(
components: list[str], device_type: str components: list[str], device_type: str
@ -661,149 +642,3 @@ class SmartDevice(Bulb, Fan, Device):
return DeviceType.Thermostat return DeviceType.Thermostat
_LOGGER.warning("Unknown device type, falling back to plug") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.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

View File

@ -18,8 +18,13 @@ class SmartModule(Module):
"""Base class for SMART modules.""" """Base class for SMART modules."""
NAME: str 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 QUERY_GETTER_NAME: str
REGISTERED_MODULES: dict[str, type[SmartModule]] = {} REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
def __init__(self, device: SmartDevice, module: str): def __init__(self, device: SmartDevice, module: str):
@ -27,8 +32,6 @@ class SmartModule(Module):
super().__init__(device, module) super().__init__(device, module)
def __init_subclass__(cls, **kwargs): def __init_subclass__(cls, **kwargs):
assert cls.REQUIRED_COMPONENT is not None # noqa: S101
name = getattr(cls, "NAME", cls.__name__) name = getattr(cls, "NAME", cls.__name__)
_LOGGER.debug("Registering %s" % cls) _LOGGER.debug("Registering %s" % cls)
cls.REGISTERED_MODULES[name] = cls cls.REGISTERED_MODULES[name] = cls
@ -91,8 +94,13 @@ class SmartModule(Module):
@property @property
def supported_version(self) -> int: def supported_version(self) -> int:
"""Return version supported by the device.""" """Return version supported by the device.
return self._device._components[self.REQUIRED_COMPONENT]
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: async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device. """Additional check to see if the module is supported by the device.

View File

@ -109,7 +109,7 @@ DIMMERS = {
} }
HUBS_SMART = {"H100", "KH100"} HUBS_SMART = {"H100", "KH100"}
SENSORS_SMART = {"T310", "T315", "T300"} SENSORS_SMART = {"T310", "T315", "T300", "T110"}
THERMOSTATS_SMART = {"KE100"} THERMOSTATS_SMART = {"KE100"}
WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} 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"} "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"}
) )
strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"})
dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"})
lightstrip = parametrize( lightstrip_iot = parametrize(
"lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"}
) )
# bulb types # bulb types
dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"})
non_dimmable = parametrize( non_dimmable_iot = parametrize(
"non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"}
) )
variable_temp = parametrize( variable_temp = parametrize(
@ -292,12 +292,12 @@ device_iot = parametrize(
def check_categories(): def check_categories():
"""Check that every fixture file is categorized.""" """Check that every fixture file is categorized."""
categorized_fixtures = set( categorized_fixtures = set(
dimmer.args[1] dimmer_iot.args[1]
+ strip.args[1] + strip.args[1]
+ plug.args[1] + plug.args[1]
+ bulb.args[1] + bulb.args[1]
+ wallswitch.args[1] + wallswitch.args[1]
+ lightstrip.args[1] + lightstrip_iot.args[1]
+ bulb_smart.args[1] + bulb_smart.args[1]
+ dimmers_smart.args[1] + dimmers_smart.args[1]
+ hubs_smart.args[1] + hubs_smart.args[1]

View File

@ -189,6 +189,11 @@ class FakeSmartTransport(BaseTransport):
if "current_rule_id" in info["get_dynamic_light_effect_rules"]: if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
del info["get_dynamic_light_effect_rules"]["current_rule_id"] 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): def _send_request(self, request_dict: dict):
method = request_dict["method"] method = request_dict["method"]
params = request_dict["params"] params = request_dict["params"]
@ -218,7 +223,9 @@ class FakeSmartTransport(BaseTransport):
# SMART fixtures started to be generated # SMART fixtures started to be generated
missing_result := self.FIXTURE_MISSING_MAP.get(method) missing_result := self.FIXTURE_MISSING_MAP.get(method)
) and missing_result[0] in self.components: ) 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} retval = {"result": result, "error_code": 0}
else: else:
# PARAMS error returned for KS240 when get_device_usage called # 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] = set()
pytest.fixtures_missing_methods[self.fixture_name].add(method) pytest.fixtures_missing_methods[self.fixture_name].add(method)
return retval return retval
elif method == "set_qs_info": elif method in ["set_qs_info", "fw_download"]:
return {"error_code": 0} return {"error_code": 0}
elif method == "set_dynamic_light_effect_rule_enable": elif method == "set_dynamic_light_effect_rule_enable":
self._set_light_effect(info, params) self._set_light_effect(info, params)
return {"error_code": 0} return {"error_code": 0}
elif method == "set_led_info":
self._set_led_info(info, params)
return {"error_code": 0}
elif method[:4] == "set_": elif method[:4] == "set_":
target_method = f"get_{method[4:]}" target_method = f"get_{method[4:]}"
info[target_method].update(params) info[target_method].update(params)

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -2,7 +2,7 @@ import pytest
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.smart import SmartDevice 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") brightness = parametrize("brightness smart", component_filter="brightness")
@ -10,13 +10,13 @@ brightness = parametrize("brightness smart", component_filter="brightness")
@brightness @brightness
async def test_brightness_component(dev: SmartDevice): async def test_brightness_component(dev: SmartDevice):
"""Test brightness feature.""" """Test brightness feature."""
brightness = dev.get_module("Brightness") brightness = dev.modules.get("Brightness")
assert brightness assert brightness
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
assert "brightness" in dev._components assert "brightness" in dev._components
# Test getting the value # Test getting the value
feature = brightness._module_features["brightness"] feature = dev.features["brightness"]
assert isinstance(feature.value, int) assert isinstance(feature.value, int)
assert feature.value > 1 and feature.value <= 100 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) await feature.set_value(feature.maximum_value + 10)
@dimmable @dimmable_iot
async def test_brightness_dimmable(dev: IotDevice): async def test_brightness_dimmable(dev: IotDevice):
"""Test brightness feature.""" """Test brightness feature."""
assert isinstance(dev, IotDevice) assert isinstance(dev, IotDevice)

View File

@ -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)

View File

@ -1,8 +1,8 @@
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Module
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smart.modules import FanModule
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) 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 @fan
async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
"""Test fan speed feature.""" """Test fan speed feature."""
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
level_feature = fan._module_features["fan_speed_level"] level_feature = dev.features["fan_speed_level"]
assert ( assert (
level_feature.minimum_value level_feature.minimum_value
<= level_feature.value <= level_feature.value
@ -36,9 +36,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
@fan @fan
async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
"""Test sleep mode feature.""" """Test sleep mode feature."""
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
sleep_feature = fan._module_features["fan_sleep_mode"] sleep_feature = dev.features["fan_sleep_mode"]
assert isinstance(sleep_feature.value, bool) assert isinstance(sleep_feature.value, bool)
call = mocker.spy(fan, "call") call = mocker.spy(fan, "call")
@ -52,29 +52,28 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
@fan @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.""" """Test fan speed on device interface."""
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
device = fan._device device = fan._device
assert device.is_fan
await device.set_fan_speed_level(1) await fan.set_fan_speed_level(1)
await dev.update() await dev.update()
assert device.fan_speed_level == 1 assert fan.fan_speed_level == 1
assert device.is_on assert device.is_on
await device.set_fan_speed_level(4) await fan.set_fan_speed_level(4)
await dev.update() 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() await dev.update()
assert not device.is_on assert not device.is_on
with pytest.raises(ValueError): with pytest.raises(ValueError):
await device.set_fan_speed_level(-1) await fan.set_fan_speed_level(-1)
with pytest.raises(ValueError): with pytest.raises(ValueError):
await device.set_fan_speed_level(5) await fan.set_fan_speed_level(5)

View File

@ -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)

View File

@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type):
prop = getattr(humidity, feature) prop = getattr(humidity, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = humidity._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
from itertools import chain from itertools import chain
from typing import cast
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, Feature from kasa import Device, Feature, Module
from kasa.smart.modules import LightEffectModule from kasa.smart.modules import LightEffect
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
light_effect = parametrize( light_effect = parametrize(
@ -18,10 +17,10 @@ light_effect = parametrize(
@light_effect @light_effect
async def test_light_effect(dev: Device, mocker: MockerFixture): async def test_light_effect(dev: Device, mocker: MockerFixture):
"""Test light effect.""" """Test light effect."""
light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) light_effect = dev.modules.get(Module.LightEffect)
assert light_effect assert isinstance(light_effect, LightEffect)
feature = light_effect._module_features["light_effect"] feature = dev.features["light_effect"]
assert feature.type == Feature.Type.Choice assert feature.type == Feature.Type.Choice
call = mocker.spy(light_effect, "call") call = mocker.spy(light_effect, "call")
@ -29,7 +28,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture):
assert feature.choices assert feature.choices
for effect in chain(reversed(feature.choices), feature.choices): for effect in chain(reversed(feature.choices), feature.choices):
await light_effect.set_effect(effect) 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} params: dict[str, bool | str] = {"enable": enable}
if enable: if enable:
params["id"] = light_effect._scenes_names_to_id[effect] params["id"] = light_effect._scenes_names_to_id[effect]

View File

@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type):
prop = getattr(temp_module, feature) prop = getattr(temp_module, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = temp_module._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)
@ -42,6 +42,6 @@ async def test_temperature_warning(dev):
assert hasattr(temp_module, "temperature_warning") assert hasattr(temp_module, "temperature_warning")
assert isinstance(temp_module.temperature_warning, bool) 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 feat.value == temp_module.temperature_warning
assert isinstance(feat.value, bool) assert isinstance(feat.value, bool)

View File

@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type):
prop = getattr(temp_module, feature) prop = getattr(temp_module, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = temp_module._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type):
prop = getattr(waterleak, prop_name) prop = getattr(waterleak, prop_name)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = waterleak._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -7,19 +7,18 @@ from voluptuous import (
Schema, 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.iot import IotBulb, IotDimmer
from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb, bulb,
bulb_iot, bulb_iot,
color_bulb, color_bulb,
color_bulb_iot, color_bulb_iot,
dimmable, dimmable_iot,
handle_turn_on, handle_turn_on,
non_color_bulb, non_color_bulb,
non_dimmable, non_dimmable_iot,
non_variable_temp, non_variable_temp,
turn_on, turn_on,
variable_temp, variable_temp,
@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb):
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev: Device, 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) 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 <= hue <= 360
assert 0 <= saturation <= 100 assert 0 <= saturation <= 100
assert 0 <= brightness <= 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() await dev.update()
hue, saturation, brightness = dev.hsv hue, saturation, brightness = light.hsv
assert hue == 1 assert hue == 1
assert saturation == 1 assert saturation == 1
assert brightness == 1 assert brightness == 1
@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker):
@color_bulb @color_bulb
@turn_on @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) await handle_turn_on(dev, turn_on)
assert dev.is_color assert light.is_color
for invalid_hue in [-1, 361, 0.5]: for invalid_hue in [-1, 361, 0.5]:
with pytest.raises(ValueError): 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]: for invalid_saturation in [-1, 101, 0.5]:
with pytest.raises(ValueError): 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]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): 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 @color_bulb
@pytest.mark.skip("requires color feature") @pytest.mark.skip("requires color feature")
async def test_color_state_information(dev: Device): 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 "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv assert dev.state_information["HSV"] == light.hsv
@non_color_bulb @non_color_bulb
async def test_hsv_on_non_color(dev: Bulb): async def test_hsv_on_non_color(dev: Device):
assert not dev.is_color light = dev.modules.get(Module.Light)
assert light
assert not light.is_color
with pytest.raises(KasaException): with pytest.raises(KasaException):
await dev.set_hsv(0, 0, 0) await light.set_hsv(0, 0, 0)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.hsv) print(light.hsv)
@variable_temp @variable_temp
@pytest.mark.skip("requires colortemp module") @pytest.mark.skip("requires colortemp module")
async def test_variable_temp_state_information(dev: Device): 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 "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 @variable_temp
@turn_on @turn_on
async def test_try_set_colortemp(dev: Device, 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 handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await light.set_color_temp(2700)
await dev.update() await dev.update()
assert dev.color_temp == 2700 assert light.color_temp == 2700
@variable_temp_iot @variable_temp_iot
@ -166,36 +173,42 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
@variable_temp_smart @variable_temp_smart
async def test_smart_temp_range(dev: SmartDevice): async def test_smart_temp_range(dev: Device):
assert dev.valid_temperature_range light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range
@variable_temp @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): with pytest.raises(ValueError):
await dev.set_color_temp(1000) await light.set_color_temp(1000)
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_color_temp(10000) await light.set_color_temp(10000)
@non_variable_temp @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): with pytest.raises(KasaException):
await dev.set_color_temp(2700) await light.set_color_temp(2700)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.valid_temperature_range) print(light.valid_temperature_range)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.color_temp) print(light.color_temp)
@dimmable @dimmable_iot
@turn_on @turn_on
async def test_dimmable_brightness(dev: Device, turn_on): async def test_dimmable_brightness(dev: IotBulb, turn_on):
assert isinstance(dev, (Bulb, IotDimmer)) assert isinstance(dev, (IotBulb, IotDimmer))
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_dimmable assert dev._is_dimmable
await dev.set_brightness(50) await dev.set_brightness(50)
await dev.update() 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) set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@dimmable @dimmable_iot
async def test_invalid_brightness(dev: Bulb): async def test_invalid_brightness(dev: IotBulb):
assert dev.is_dimmable assert dev._is_dimmable
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_brightness(110) await dev.set_brightness(110)
@ -240,9 +253,9 @@ async def test_invalid_brightness(dev: Bulb):
await dev.set_brightness(-100) await dev.set_brightness(-100)
@non_dimmable @non_dimmable_iot
async def test_non_dimmable(dev: Bulb): async def test_non_dimmable(dev: IotBulb):
assert not dev.is_dimmable assert not dev._is_dimmable
with pytest.raises(KasaException): with pytest.raises(KasaException):
assert dev.brightness == 0 assert dev.brightness == 0
@ -291,7 +304,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
"saturation": 0, "saturation": 0,
"color_temp": 0, "color_temp": 0,
} }
preset = BulbPreset(**data) preset = LightPreset(**data)
assert preset.index == 0 assert preset.index == 0
assert preset.brightness == 10 assert preset.brightness == 10
@ -305,7 +318,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
with pytest.raises(KasaException): with pytest.raises(KasaException):
await dev.save_preset( 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"), ("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}, {"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}, {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
), ),
], ],
@ -380,7 +393,7 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
@bulb @bulb
def test_device_type_bulb(dev): def test_device_type_bulb(dev: Device):
if dev.is_light_strip: if dev.is_light_strip:
pytest.skip("bulb has also lightstrips to test the api") pytest.skip("bulb has also lightstrips to test the api")
assert dev.device_type == DeviceType.Bulb assert dev.device_type == DeviceType.Bulb

View File

@ -13,6 +13,7 @@ from kasa import (
DeviceError, DeviceError,
EmeterStatus, EmeterStatus,
KasaException, KasaException,
Module,
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.cli import ( from kasa.cli import (
@ -21,11 +22,15 @@ from kasa.cli import (
brightness, brightness,
cli, cli,
cmd_command, cmd_command,
effect,
emeter, emeter,
hsv,
led,
raw_command, raw_command,
reboot, reboot,
state, state,
sysinfo, sysinfo,
temperature,
toggle, toggle,
update_credentials, update_credentials,
wifi, wifi,
@ -34,7 +39,6 @@ from kasa.discover import Discover, DiscoveryResult
from kasa.iot import IotDevice from kasa.iot import IotDevice
from .conftest import ( from .conftest import (
device_iot,
device_smart, device_smart,
get_device_for_fixture_protocol, get_device_for_fixture_protocol,
handle_turn_on, handle_turn_on,
@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner):
update.assert_called() update.assert_called()
@device_iot async def test_sysinfo(dev: Device, runner):
async def test_sysinfo(dev, runner):
res = await runner.invoke(sysinfo, obj=dev) res = await runner.invoke(sysinfo, obj=dev)
assert "System info" in res.output assert "System info" in res.output
assert dev.alias in res.output assert dev.model in res.output
@turn_on @turn_on
@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner):
assert dev.is_on != turn_on assert dev.is_on != turn_on
@device_iot
async def test_alias(dev, runner): async def test_alias(dev, runner):
res = await runner.invoke(alias, obj=dev) res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output 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) daily.assert_called_with(year=1900, month=12)
@device_iot async def test_brightness(dev: Device, runner):
async def test_brightness(dev, runner):
res = await runner.invoke(brightness, obj=dev) 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 assert "This device does not support brightness." in res.output
return return
res = await runner.invoke(brightness, obj=dev) 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) res = await runner.invoke(brightness, ["12"], obj=dev)
assert "Setting brightness" in res.output assert "Setting brightness" in res.output
@ -326,7 +327,110 @@ async def test_brightness(dev, runner):
assert "Brightness: 12" in res.output 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): async def test_json_output(dev: Device, mocker, runner):
"""Test that the json output produces correct output.""" """Test that the json output produces correct output."""
mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) 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 assert "Username:foo Password:bar\n" in res.output
@device_iot
async def test_without_device_type(dev, mocker, runner): async def test_without_device_type(dev, mocker, runner):
"""Test connecting without the device type.""" """Test connecting without the device type."""
discovery_mock = mocker.patch( discovery_mock = mocker.patch(
@ -737,7 +840,7 @@ async def test_feature_set(mocker, runner):
dummy_device = await get_device_for_fixture_protocol( dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART" "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) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
res = await runner.invoke( res = await runner.invoke(

View File

@ -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)

View File

@ -9,7 +9,7 @@ from unittest.mock import Mock, patch
import pytest import pytest
import kasa import kasa
from kasa import Credentials, Device, DeviceConfig from kasa import Credentials, Device, DeviceConfig, DeviceType
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
@ -25,6 +25,7 @@ def _get_subclasses(of_class):
inspect.isclass(obj) inspect.isclass(obj)
and issubclass(obj, of_class) and issubclass(obj, of_class)
and module.__package__ != "kasa" and module.__package__ != "kasa"
and module.__package__ != "kasa.interfaces"
): ):
subclasses.add((module.__package__ + "." + name, obj)) subclasses.add((module.__package__ + "." + name, obj))
return subclasses return subclasses
@ -120,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class):
with pytest.deprecated_call(match=msg): with pytest.deprecated_call(match=msg):
getattr(kasa, exceptions_class) getattr(kasa, exceptions_class)
getattr(kasa, use_class.__name__) 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

View File

@ -3,10 +3,10 @@ import pytest
from kasa import DeviceType from kasa import DeviceType
from kasa.iot import IotDimmer 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 @turn_on
async def test_set_brightness(dev, turn_on): async def test_set_brightness(dev, turn_on):
await handle_turn_on(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 assert dev.is_on == turn_on
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_brightness_transition(dev, turn_on, mocker): async def test_set_brightness_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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 assert dev.brightness == 1
@dimmer @dimmer_iot
async def test_set_brightness_invalid(dev): async def test_set_brightness_invalid(dev):
for invalid_brightness in [-1, 101, 0.5]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev):
await dev.set_brightness(1, transition=invalid_transition) await dev.set_brightness(1, transition=invalid_transition)
@dimmer @dimmer_iot
async def test_turn_on_transition(dev, mocker): async def test_turn_on_transition(dev, mocker):
query_helper = mocker.spy(IotDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
original_brightness = dev.brightness original_brightness = dev.brightness
@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker):
assert dev.brightness == original_brightness assert dev.brightness == original_brightness
@dimmer @dimmer_iot
async def test_turn_off_transition(dev, mocker): async def test_turn_off_transition(dev, mocker):
await handle_turn_on(dev, True) await handle_turn_on(dev, True)
query_helper = mocker.spy(IotDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker):
) )
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_dimmer_transition(dev, turn_on, mocker): async def test_set_dimmer_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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 assert dev.brightness == 99
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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): async def test_set_dimmer_transition_invalid(dev):
for invalid_brightness in [-1, 101, 0.5]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev):
await dev.set_dimmer_transition(1, invalid_transition) await dev.set_dimmer_transition(1, invalid_transition)
@dimmer @dimmer_iot
def test_device_type_dimmer(dev): def test_device_type_dimmer(dev):
assert dev.device_type == DeviceType.Dimmer assert dev.device_type == DeviceType.Dimmer

View File

@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption
from .conftest import ( from .conftest import (
bulb_iot, bulb_iot,
dimmer, dimmer_iot,
lightstrip, lightstrip_iot,
new_discovery, new_discovery,
plug_iot, plug_iot,
strip_iot, strip_iot,
@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device):
assert d.device_type == DeviceType.Strip assert d.device_type == DeviceType.Strip
@dimmer @dimmer_iot
async def test_type_detection_dimmer(dev: Device): async def test_type_detection_dimmer(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_dimmer assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer assert d.device_type == DeviceType.Dimmer
@lightstrip @lightstrip_iot
async def test_type_detection_lightstrip(dev: Device): async def test_type_detection_lightstrip(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_light_strip assert d.is_light_strip

View File

@ -1,5 +1,6 @@
import logging import logging
import sys import sys
from unittest.mock import patch
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@ -180,11 +181,10 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture):
async def _test_features(dev): async def _test_features(dev):
exceptions = [] exceptions = []
query = mocker.patch.object(dev.protocol, "query")
for feat in dev.features.values(): for feat in dev.features.values():
query.reset_mock()
try: 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 # we allow our own exceptions to avoid mocking valid responses
except KasaException: except KasaException:
pass pass

View File

@ -16,7 +16,7 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import KasaException from kasa import KasaException, Module
from kasa.iot import IotDevice from kasa.iot import IotDevice
from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on 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(): 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( dummy_device = await get_device_for_fixture_protocol(
"HS100(US)_2.0_1.5.6.json", "IOT" "HS100(US)_2.0_1.5.6.json", "IOT"
) )
from kasa.iot.modules import Cloud from kasa.iot.modules import Cloud
from kasa.smart.modules import CloudModule
# Modules on device # Modules on device
module = dummy_device.get_module("Cloud") module = dummy_device.modules.get("cloud")
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, Cloud) assert isinstance(module, Cloud)
module = dummy_device.get_module(Cloud) module = dummy_device.modules.get(Module.IotCloud)
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, Cloud) assert isinstance(module, Cloud)
# Invalid modules # Invalid modules
module = dummy_device.get_module("DummyModule") module = dummy_device.modules.get("DummyModule")
assert module is None assert module is None
module = dummy_device.get_module(CloudModule) module = dummy_device.modules.get(Module.Cloud)
assert module is None assert module is None

View File

@ -1,29 +1,28 @@
import pytest import pytest
from kasa import DeviceType from kasa import DeviceType
from kasa.exceptions import KasaException
from kasa.iot import IotLightStrip from kasa.iot import IotLightStrip
from .conftest import lightstrip from .conftest import lightstrip_iot
@lightstrip @lightstrip_iot
async def test_lightstrip_length(dev: IotLightStrip): async def test_lightstrip_length(dev: IotLightStrip):
assert dev.is_light_strip assert dev.is_light_strip
assert dev.device_type == DeviceType.LightStrip assert dev.device_type == DeviceType.LightStrip
assert dev.length == dev.sys_info["length"] assert dev.length == dev.sys_info["length"]
@lightstrip @lightstrip_iot
async def test_lightstrip_effect(dev: IotLightStrip): async def test_lightstrip_effect(dev: IotLightStrip):
assert isinstance(dev.effect, dict) assert isinstance(dev.effect, dict)
for k in ["brightness", "custom", "enable", "id", "name"]: for k in ["brightness", "custom", "enable", "id", "name"]:
assert k in dev.effect assert k in dev.effect
@lightstrip @lightstrip_iot
async def test_effects_lightstrip_set_effect(dev: IotLightStrip): 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("Not real")
await dev.set_effect("Candy Cane") 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" assert dev.effect["name"] == "Candy Cane"
@lightstrip @lightstrip_iot
@pytest.mark.parametrize("brightness", [100, 50]) @pytest.mark.parametrize("brightness", [100, 50])
async def test_effects_lightstrip_set_effect_brightness( async def test_effects_lightstrip_set_effect_brightness(
dev: IotLightStrip, brightness, mocker dev: IotLightStrip, brightness, mocker
@ -49,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness(
assert payload["brightness"] == brightness assert payload["brightness"] == brightness
@lightstrip @lightstrip_iot
@pytest.mark.parametrize("transition", [500, 1000]) @pytest.mark.parametrize("transition", [500, 1000])
async def test_effects_lightstrip_set_effect_transition( async def test_effects_lightstrip_set_effect_transition(
dev: IotLightStrip, transition, mocker dev: IotLightStrip, transition, mocker
@ -67,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition(
assert payload["transition"] == transition assert payload["transition"] == transition
@lightstrip @lightstrip_iot
async def test_effects_lightstrip_has_effects(dev: IotLightStrip): async def test_effects_lightstrip_has_effects(dev: IotLightStrip):
assert dev.has_effects is True assert dev.has_effects is True
assert dev.effect_list assert dev.effect_list
@lightstrip @lightstrip_iot
def test_device_type_lightstrip(dev): def test_device_type_lightstrip(dev):
assert dev.device_type == DeviceType.LightStrip assert dev.device_type == DeviceType.LightStrip

View File

@ -9,12 +9,11 @@ from unittest.mock import patch
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import KasaException from kasa import KasaException, Module
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb_smart,
device_smart, device_smart,
get_device_for_fixture_protocol, get_device_for_fixture_protocol,
) )
@ -123,65 +122,42 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
async def test_get_modules(): 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( dummy_device = await get_device_for_fixture_protocol(
"KS240(US)_1.0_1.0.5.json", "SMART" "KS240(US)_1.0_1.0.5.json", "SMART"
) )
from kasa.iot.modules import AmbientLight from kasa.smart.modules import Cloud
from kasa.smart.modules import CloudModule, FanModule
# Modules on device # Modules on device
module = dummy_device.get_module("CloudModule") module = dummy_device.modules.get("Cloud")
assert module assert module
assert module._device == dummy_device 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
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, CloudModule) assert isinstance(module, Cloud)
# Modules on child # Modules on child
module = dummy_device.get_module("FanModule") module = dummy_device.modules.get("Fan")
assert module assert module
assert module._device != dummy_device assert module._device != dummy_device
assert module._device._parent == 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
assert module._device != dummy_device assert module._device != dummy_device
assert module._device._parent == dummy_device assert module._device._parent == dummy_device
# Invalid modules # Invalid modules
module = dummy_device.get_module("DummyModule") module = dummy_device.modules.get("DummyModule")
assert module is None assert module is None
module = dummy_device.get_module(AmbientLight) module = dummy_device.modules.get(Module.IotAmbientLight)
assert module is None 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 @device_smart
async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture):
"""Test is_cloud_connected property.""" """Test is_cloud_connected property."""