mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-05-20 13:31:15 +00:00
Merge remote-tracking branch 'upstream/master' into feat/device_update
This commit is contained in:
commit
06c5e55ac6
@ -21,6 +21,11 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-click]
|
||||
exclude: |
|
||||
(?x)^(
|
||||
kasa/modulemapping\.py|
|
||||
)$
|
||||
|
||||
|
||||
- repo: https://github.com/PyCQA/doc8
|
||||
rev: 'v1.1.1'
|
||||
|
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal 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.
|
38
README.md
38
README.md
@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages:
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions.
|
||||
|
||||
### Setting up development environment
|
||||
|
||||
To get started, simply clone this repository and initialize the development environment.
|
||||
We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute
|
||||
`poetry install` which will install all necessary packages and create a virtual environment for you.
|
||||
|
||||
### Code-style checks
|
||||
|
||||
We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly
|
||||
before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`.
|
||||
This will make sure that the checks are passing when you do a commit.
|
||||
|
||||
You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests.
|
||||
|
||||
### Running tests
|
||||
|
||||
You can run tests on the library by executing `pytest` in the source directory.
|
||||
This will run the tests against contributed example responses, but you can also execute the tests against a real device:
|
||||
```
|
||||
$ pytest --ip <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.
|
||||
|
||||
Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported.
|
||||
Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html).
|
||||
|
||||
## Supported devices
|
||||
|
||||
The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).
|
||||
The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files).
|
||||
|
||||
<!--Do not edit text inside the SUPPORTED section below -->
|
||||
<!--SUPPORTED_START-->
|
||||
@ -242,7 +212,7 @@ The following devices have been tested and confirmed as working. If your device
|
||||
- **Bulbs**: L510B, L510E, L530E
|
||||
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
|
||||
- **Hubs**: H100
|
||||
- **Hub-Connected Devices<sup>\*\*\*</sup>**: T300, T310, T315
|
||||
- **Hub-Connected Devices<sup>\*\*\*</sup>**: T110, T300, T310, T315
|
||||
|
||||
<!--SUPPORTED_END-->
|
||||
<sup>\*</sup> Model requires authentication<br>
|
||||
|
@ -137,6 +137,7 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
|
||||
### Hub-Connected Devices
|
||||
|
||||
- **KE100**
|
||||
- Hardware: 1.0 (EU) / Firmware: 2.4.0<sup>\*</sup>
|
||||
- Hardware: 1.0 (EU) / 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**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.2.3
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.5.10
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.5.5
|
||||
|
||||
### Hub-Connected Devices
|
||||
|
||||
- **T110**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.8.0
|
||||
- **T300**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.7.0
|
||||
- **T310**
|
||||
|
@ -19,7 +19,7 @@ app = typer.Typer()
|
||||
def create_fixtures(dev: IotDevice, outputdir: Path):
|
||||
"""Iterate over supported modules and create version-specific fixture files."""
|
||||
for name, module in dev.modules.items():
|
||||
module_dir = outputdir / name
|
||||
module_dir = outputdir / str(name)
|
||||
if not module_dir.exists():
|
||||
module_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
|
86
docs/source/contribute.md
Normal file
86
docs/source/contribute.md
Normal 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`.
|
||||
```
|
@ -10,6 +10,7 @@
|
||||
discover
|
||||
smartdevice
|
||||
design
|
||||
contribute
|
||||
smartbulb
|
||||
smartplug
|
||||
smartdimmer
|
||||
|
@ -16,7 +16,6 @@ from importlib.metadata import version
|
||||
from typing import TYPE_CHECKING
|
||||
from warnings import warn
|
||||
|
||||
from kasa.bulb import Bulb
|
||||
from kasa.credentials import Credentials
|
||||
from kasa.device import Device
|
||||
from kasa.device_type import DeviceType
|
||||
@ -36,13 +35,12 @@ from kasa.exceptions import (
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.feature import Feature
|
||||
from kasa.firmware import Firmware, FirmwareUpdate
|
||||
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
|
||||
from kasa.interfaces.light import Light, LightPreset
|
||||
from kasa.iotprotocol import (
|
||||
IotProtocol,
|
||||
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401
|
||||
)
|
||||
from kasa.plug import Plug
|
||||
from kasa.module import Module
|
||||
from kasa.protocol import BaseProtocol
|
||||
from kasa.smartprotocol import SmartProtocol
|
||||
|
||||
@ -54,15 +52,16 @@ __all__ = [
|
||||
"BaseProtocol",
|
||||
"IotProtocol",
|
||||
"SmartProtocol",
|
||||
"BulbPreset",
|
||||
"LightPreset",
|
||||
"TurnOnBehaviors",
|
||||
"TurnOnBehavior",
|
||||
"DeviceType",
|
||||
"Feature",
|
||||
"EmeterStatus",
|
||||
"Device",
|
||||
"Bulb",
|
||||
"Light",
|
||||
"Plug",
|
||||
"Module",
|
||||
"KasaException",
|
||||
"AuthenticationError",
|
||||
"DeviceError",
|
||||
@ -73,8 +72,6 @@ __all__ = [
|
||||
"ConnectionType",
|
||||
"EncryptType",
|
||||
"DeviceFamilyType",
|
||||
"Firmware",
|
||||
"FirmwareUpdate",
|
||||
]
|
||||
|
||||
from . import iot
|
||||
@ -87,7 +84,7 @@ deprecated_smart_devices = {
|
||||
"SmartLightStrip": iot.IotLightStrip,
|
||||
"SmartStrip": iot.IotStrip,
|
||||
"SmartDimmer": iot.IotDimmer,
|
||||
"SmartBulbPreset": BulbPreset,
|
||||
"SmartBulbPreset": LightPreset,
|
||||
}
|
||||
deprecated_exceptions = {
|
||||
"SmartDeviceException": KasaException,
|
||||
@ -127,7 +124,7 @@ if TYPE_CHECKING:
|
||||
SmartLightStrip = iot.IotLightStrip
|
||||
SmartStrip = iot.IotStrip
|
||||
SmartDimmer = iot.IotDimmer
|
||||
SmartBulbPreset = BulbPreset
|
||||
SmartBulbPreset = LightPreset
|
||||
|
||||
SmartDeviceException = KasaException
|
||||
UnsupportedDeviceException = UnsupportedDeviceError
|
||||
|
82
kasa/cli.py
82
kasa/cli.py
@ -18,7 +18,6 @@ from pydantic.v1 import ValidationError
|
||||
|
||||
from kasa import (
|
||||
AuthenticationError,
|
||||
Bulb,
|
||||
ConnectionType,
|
||||
Credentials,
|
||||
Device,
|
||||
@ -28,6 +27,7 @@ from kasa import (
|
||||
EncryptType,
|
||||
Feature,
|
||||
KasaException,
|
||||
Module,
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.discover import DiscoveryResult
|
||||
@ -859,18 +859,18 @@ async def usage(dev: Device, year, month, erase):
|
||||
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
async def brightness(dev: Bulb, brightness: int, transition: int):
|
||||
async def brightness(dev: Device, brightness: int, transition: int):
|
||||
"""Get or set brightness."""
|
||||
if not dev.is_dimmable:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
||||
echo("This device does not support brightness.")
|
||||
return
|
||||
|
||||
if brightness is None:
|
||||
echo(f"Brightness: {dev.brightness}")
|
||||
return dev.brightness
|
||||
echo(f"Brightness: {light.brightness}")
|
||||
return light.brightness
|
||||
else:
|
||||
echo(f"Setting brightness to {brightness}")
|
||||
return await dev.set_brightness(brightness, transition=transition)
|
||||
return await light.set_brightness(brightness, transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@ -879,15 +879,15 @@ async def brightness(dev: Bulb, brightness: int, transition: int):
|
||||
)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
async def temperature(dev: Bulb, temperature: int, transition: int):
|
||||
async def temperature(dev: Device, temperature: int, transition: int):
|
||||
"""Get or set color temperature."""
|
||||
if not dev.is_variable_color_temp:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
||||
echo("Device does not support color temperature")
|
||||
return
|
||||
|
||||
if temperature is None:
|
||||
echo(f"Color temperature: {dev.color_temp}")
|
||||
valid_temperature_range = dev.valid_temperature_range
|
||||
echo(f"Color temperature: {light.color_temp}")
|
||||
valid_temperature_range = light.valid_temperature_range
|
||||
if valid_temperature_range != (0, 0):
|
||||
echo("(min: {}, max: {})".format(*valid_temperature_range))
|
||||
else:
|
||||
@ -895,31 +895,34 @@ async def temperature(dev: Bulb, temperature: int, transition: int):
|
||||
"Temperature range unknown, please open a github issue"
|
||||
f" or a pull request for model '{dev.model}'"
|
||||
)
|
||||
return dev.valid_temperature_range
|
||||
return light.valid_temperature_range
|
||||
else:
|
||||
echo(f"Setting color temperature to {temperature}")
|
||||
return await dev.set_color_temp(temperature, transition=transition)
|
||||
return await light.set_color_temp(temperature, transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("effect", type=click.STRING, default=None, required=False)
|
||||
@click.pass_context
|
||||
@pass_dev
|
||||
async def effect(dev, ctx, effect):
|
||||
async def effect(dev: Device, ctx, effect):
|
||||
"""Set an effect."""
|
||||
if not dev.has_effects:
|
||||
if not (light_effect := dev.modules.get(Module.LightEffect)):
|
||||
echo("Device does not support effects")
|
||||
return
|
||||
if effect is None:
|
||||
raise click.BadArgumentUsage(
|
||||
f"Setting an effect requires a named built-in effect: {dev.effect_list}",
|
||||
"Setting an effect requires a named built-in effect: "
|
||||
+ f"{light_effect.effect_list}",
|
||||
ctx,
|
||||
)
|
||||
if effect not in dev.effect_list:
|
||||
raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx)
|
||||
if effect not in light_effect.effect_list:
|
||||
raise click.BadArgumentUsage(
|
||||
f"Effect must be one of: {light_effect.effect_list}", ctx
|
||||
)
|
||||
|
||||
echo(f"Setting Effect: {effect}")
|
||||
return await dev.set_effect(effect)
|
||||
return await light_effect.set_effect(effect)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@ -929,33 +932,36 @@ async def effect(dev, ctx, effect):
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@click.pass_context
|
||||
@pass_dev
|
||||
async def hsv(dev, ctx, h, s, v, transition):
|
||||
async def hsv(dev: Device, ctx, h, s, v, transition):
|
||||
"""Get or set color in HSV."""
|
||||
if not dev.is_color:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
||||
echo("Device does not support colors")
|
||||
return
|
||||
|
||||
if h is None or s is None or v is None:
|
||||
echo(f"Current HSV: {dev.hsv}")
|
||||
return dev.hsv
|
||||
if h is None and s is None and v is None:
|
||||
echo(f"Current HSV: {light.hsv}")
|
||||
return light.hsv
|
||||
elif s is None or v is None:
|
||||
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
|
||||
else:
|
||||
echo(f"Setting HSV: {h} {s} {v}")
|
||||
return await dev.set_hsv(h, s, v, transition=transition)
|
||||
return await light.set_hsv(h, s, v, transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("state", type=bool, required=False)
|
||||
@pass_dev
|
||||
async def led(dev, state):
|
||||
async def led(dev: Device, state):
|
||||
"""Get or set (Plug's) led state."""
|
||||
if not (led := dev.modules.get(Module.Led)):
|
||||
echo("Device does not support led.")
|
||||
return
|
||||
if state is not None:
|
||||
echo(f"Turning led to {state}")
|
||||
return await dev.set_led(state)
|
||||
return await led.set_led(state)
|
||||
else:
|
||||
echo(f"LED state: {dev.led}")
|
||||
return dev.led
|
||||
echo(f"LED state: {led.led}")
|
||||
return led.led
|
||||
|
||||
|
||||
@cli.command()
|
||||
@ -975,8 +981,8 @@ async def time(dev):
|
||||
async def on(dev: Device, index: int, name: str, transition: int):
|
||||
"""Turn the device on."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.is_strip:
|
||||
echo("Index and name are only for power strips!")
|
||||
if not dev.children:
|
||||
echo("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
@ -996,8 +1002,8 @@ async def on(dev: Device, index: int, name: str, transition: int):
|
||||
async def off(dev: Device, index: int, name: str, transition: int):
|
||||
"""Turn the device off."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.is_strip:
|
||||
echo("Index and name are only for power strips!")
|
||||
if not dev.children:
|
||||
echo("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
@ -1017,8 +1023,8 @@ async def off(dev: Device, index: int, name: str, transition: int):
|
||||
async def toggle(dev: Device, index: int, name: str, transition: int):
|
||||
"""Toggle the device on/off."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.is_strip:
|
||||
echo("Index and name are only for power strips!")
|
||||
if not dev.children:
|
||||
echo("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
@ -1266,7 +1272,11 @@ async def firmware(ctx: click.Context, dev: Device):
|
||||
@click.pass_context
|
||||
async def firmware_info(ctx: click.Context, dev: Device):
|
||||
"""Return firmware information."""
|
||||
res = await dev.firmware.check_for_updates()
|
||||
if not (firmware := dev.modules.get(Module.Firmware)):
|
||||
echo("This device does not support firmware info.")
|
||||
return
|
||||
|
||||
res = await firmware.check_for_updates()
|
||||
if res.update_available:
|
||||
echo("[green bold]Update available![/green bold]")
|
||||
echo(f"Current firmware: {res.current_version}")
|
||||
@ -1291,7 +1301,7 @@ async def firmware_update(ctx: click.Context, dev: Device):
|
||||
echo(f"Progress: {x}")
|
||||
|
||||
echo("Going to update %s", dev)
|
||||
await dev.firmware.update_firmware(progress_cb=progress) # type: ignore
|
||||
await dev.modules.get[Module.Firmware].update_firmware(progress_cb=progress) # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
133
kasa/device.py
133
kasa/device.py
@ -6,7 +6,8 @@ import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping, Sequence, overload
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Sequence
|
||||
from warnings import warn
|
||||
|
||||
from .credentials import Credentials
|
||||
from .device_type import DeviceType
|
||||
@ -14,12 +15,14 @@ from .deviceconfig import DeviceConfig
|
||||
from .emeterstatus import EmeterStatus
|
||||
from .exceptions import KasaException
|
||||
from .feature import Feature
|
||||
from .firmware import Firmware
|
||||
from .iotprotocol import IotProtocol
|
||||
from .module import Module, ModuleT
|
||||
from .module import Module
|
||||
from .protocol import BaseProtocol
|
||||
from .xortransport import XorTransport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .modulemapping import ModuleMapping
|
||||
|
||||
|
||||
@dataclass
|
||||
class WifiNetwork:
|
||||
@ -114,21 +117,9 @@ class Device(ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def modules(self) -> Mapping[str, Module]:
|
||||
def modules(self) -> ModuleMapping[Module]:
|
||||
"""Return the device modules."""
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def get_module(self, module_type: str) -> Module | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None:
|
||||
"""Return the module from the device modules or None if not present."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_on(self) -> bool:
|
||||
@ -218,61 +209,6 @@ class Device(ABC):
|
||||
def sys_info(self) -> dict[str, Any]:
|
||||
"""Returns the device info."""
|
||||
|
||||
@property
|
||||
def is_bulb(self) -> bool:
|
||||
"""Return True if the device is a bulb."""
|
||||
return self.device_type == DeviceType.Bulb
|
||||
|
||||
@property
|
||||
def is_light_strip(self) -> bool:
|
||||
"""Return True if the device is a led strip."""
|
||||
return self.device_type == DeviceType.LightStrip
|
||||
|
||||
@property
|
||||
def is_plug(self) -> bool:
|
||||
"""Return True if the device is a plug."""
|
||||
return self.device_type == DeviceType.Plug
|
||||
|
||||
@property
|
||||
def is_wallswitch(self) -> bool:
|
||||
"""Return True if the device is a switch."""
|
||||
return self.device_type == DeviceType.WallSwitch
|
||||
|
||||
@property
|
||||
def is_strip(self) -> bool:
|
||||
"""Return True if the device is a strip."""
|
||||
return self.device_type == DeviceType.Strip
|
||||
|
||||
@property
|
||||
def is_strip_socket(self) -> bool:
|
||||
"""Return True if the device is a strip socket."""
|
||||
return self.device_type == DeviceType.StripSocket
|
||||
|
||||
@property
|
||||
def is_dimmer(self) -> bool:
|
||||
"""Return True if the device is a dimmer."""
|
||||
return self.device_type == DeviceType.Dimmer
|
||||
|
||||
@property
|
||||
def is_dimmable(self) -> bool:
|
||||
"""Return True if the device is dimmable."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_fan(self) -> bool:
|
||||
"""Return True if the device is a fan."""
|
||||
return self.device_type == DeviceType.Fan
|
||||
|
||||
@property
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
"""Return True if the device supports color temperature."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_color(self) -> bool:
|
||||
"""Return True if the device supports color changes."""
|
||||
return False
|
||||
|
||||
def get_plug_by_name(self, name: str) -> Device:
|
||||
"""Return child device for the given name."""
|
||||
for p in self.children:
|
||||
@ -289,11 +225,6 @@ class Device(ABC):
|
||||
)
|
||||
return self.children[index]
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def firmware(self) -> Firmware:
|
||||
"""Return firmware."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def time(self) -> datetime:
|
||||
@ -398,3 +329,53 @@ class Device(ABC):
|
||||
if self._last_update is None:
|
||||
return f"<{self.device_type} at {self.host} - update() needed>"
|
||||
return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>"
|
||||
|
||||
_deprecated_attributes = {
|
||||
# is_type
|
||||
"is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb),
|
||||
"is_dimmer": (
|
||||
Module.Light,
|
||||
lambda self: self.device_type == DeviceType.Dimmer,
|
||||
),
|
||||
"is_light_strip": (
|
||||
Module.LightEffect,
|
||||
lambda self: self.device_type == DeviceType.LightStrip,
|
||||
),
|
||||
"is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug),
|
||||
"is_wallswitch": (
|
||||
Module.Led,
|
||||
lambda self: self.device_type == DeviceType.WallSwitch,
|
||||
),
|
||||
"is_strip": (None, lambda self: self.device_type == DeviceType.Strip),
|
||||
"is_strip_socket": (
|
||||
None,
|
||||
lambda self: self.device_type == DeviceType.StripSocket,
|
||||
), # TODO
|
||||
# is_light_function
|
||||
"is_color": (
|
||||
Module.Light,
|
||||
lambda self: Module.Light in self.modules
|
||||
and self.modules[Module.Light].is_color,
|
||||
),
|
||||
"is_dimmable": (
|
||||
Module.Light,
|
||||
lambda self: Module.Light in self.modules
|
||||
and self.modules[Module.Light].is_dimmable,
|
||||
),
|
||||
"is_variable_color_temp": (
|
||||
Module.Light,
|
||||
lambda self: Module.Light in self.modules
|
||||
and self.modules[Module.Light].is_variable_color_temp,
|
||||
),
|
||||
}
|
||||
|
||||
def __getattr__(self, name) -> bool:
|
||||
if name in self._deprecated_attributes:
|
||||
module = self._deprecated_attributes[name][0]
|
||||
func = self._deprecated_attributes[name][1]
|
||||
msg = f"{name} is deprecated"
|
||||
if module:
|
||||
msg += f", use: {module} in device.modules instead"
|
||||
warn(msg, DeprecationWarning, stacklevel=1)
|
||||
return func(self)
|
||||
raise AttributeError(f"Device has no attribute {name!r}")
|
||||
|
16
kasa/interfaces/__init__.py
Normal file
16
kasa/interfaces/__init__.py
Normal 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",
|
||||
]
|
@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from .device import Device
|
||||
from ..module import Module
|
||||
|
||||
|
||||
class Fan(Device, ABC):
|
||||
class Fan(Module, ABC):
|
||||
"""Interface for a Fan."""
|
||||
|
||||
@property
|
@ -5,13 +5,25 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
from ..module import Module
|
||||
|
||||
UpdateResult = bool
|
||||
|
||||
|
||||
class FirmwareDownloadState(ABC):
|
||||
"""Download state."""
|
||||
|
||||
status: int
|
||||
progress: int
|
||||
reboot_time: int
|
||||
upgrade_time: int
|
||||
auto_upgrade: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareUpdate:
|
||||
class FirmwareUpdateInfo:
|
||||
"""Update info status object."""
|
||||
|
||||
update_available: bool | None = None
|
||||
@ -21,21 +33,19 @@ class FirmwareUpdate:
|
||||
release_notes: str | None = None
|
||||
|
||||
|
||||
class Firmware(ABC):
|
||||
class Firmware(Module, ABC):
|
||||
"""Interface to access firmware information and perform updates."""
|
||||
|
||||
@abstractmethod
|
||||
async def update_firmware(
|
||||
self, *, progress_cb: Callable[[Any, Any], Awaitable]
|
||||
self, *, progress_cb: Callable[[FirmwareDownloadState], Coroutine] | None = None
|
||||
) -> UpdateResult:
|
||||
"""Perform firmware update.
|
||||
|
||||
This "blocks" until the update process has finished.
|
||||
You can set *progress_cb* to get progress updates.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def check_for_updates(self) -> FirmwareUpdate:
|
||||
"""Return information about available updates."""
|
||||
raise NotImplementedError
|
||||
async def check_for_updates(self) -> FirmwareUpdateInfo:
|
||||
"""Return firmware update information."""
|
38
kasa/interfaces/led.py
Normal file
38
kasa/interfaces/led.py
Normal 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."""
|
@ -7,7 +7,7 @@ from typing import NamedTuple, Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from .device import Device
|
||||
from ..module import Module
|
||||
|
||||
|
||||
class ColorTempRange(NamedTuple):
|
||||
@ -25,8 +25,8 @@ class HSV(NamedTuple):
|
||||
value: int
|
||||
|
||||
|
||||
class BulbPreset(BaseModel):
|
||||
"""Bulb configuration preset."""
|
||||
class LightPreset(BaseModel):
|
||||
"""Light configuration preset."""
|
||||
|
||||
index: int
|
||||
brightness: int
|
||||
@ -42,23 +42,19 @@ class BulbPreset(BaseModel):
|
||||
mode: Optional[int] # noqa: UP007
|
||||
|
||||
|
||||
class Bulb(Device, ABC):
|
||||
"""Base class for TP-Link Bulb."""
|
||||
class Light(Module, ABC):
|
||||
"""Base class for TP-Link Light."""
|
||||
|
||||
def _raise_for_invalid_brightness(self, value):
|
||||
if not isinstance(value, int) or not (0 <= value <= 100):
|
||||
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_dimmable(self) -> bool:
|
||||
"""Whether the light supports brightness changes."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_color(self) -> bool:
|
||||
"""Whether the bulb supports color changes."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_dimmable(self) -> bool:
|
||||
"""Whether the bulb supports brightness changes."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
@ -137,8 +133,3 @@ class Bulb(Device, ABC):
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def presets(self) -> list[BulbPreset]:
|
||||
"""Return a list of available bulb setting presets."""
|
80
kasa/interfaces/lighteffect.py
Normal file
80
kasa/interfaces/lighteffect.py
Normal 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
|
||||
"""
|
@ -9,13 +9,22 @@ from typing import Optional, cast
|
||||
|
||||
from pydantic.v1 import BaseModel, Field, root_validator
|
||||
|
||||
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..feature import Feature
|
||||
from ..interfaces.light import HSV, ColorTempRange, LightPreset
|
||||
from ..module import Module
|
||||
from ..protocol import BaseProtocol
|
||||
from .iotdevice import IotDevice, KasaException, requires_update
|
||||
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
|
||||
from .modules import (
|
||||
Antitheft,
|
||||
Cloud,
|
||||
Countdown,
|
||||
Emeter,
|
||||
Light,
|
||||
Schedule,
|
||||
Time,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
||||
class BehaviorMode(str, Enum):
|
||||
@ -87,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IotBulb(IotDevice, Bulb):
|
||||
class IotBulb(IotDevice):
|
||||
r"""Representation of a TP-Link Smart Bulb.
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
@ -169,9 +178,9 @@ class IotBulb(IotDevice, Bulb):
|
||||
Bulb configuration presets can be accessed using the :func:`presets` property:
|
||||
|
||||
>>> bulb.presets
|
||||
[BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
|
||||
[LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
|
||||
|
||||
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
|
||||
To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset`
|
||||
instance to :func:`save_preset` method:
|
||||
|
||||
>>> preset = bulb.presets[0]
|
||||
@ -198,64 +207,40 @@ class IotBulb(IotDevice, Bulb):
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Bulb
|
||||
self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule"))
|
||||
self.add_module("usage", Usage(self, "smartlife.iot.common.schedule"))
|
||||
self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft"))
|
||||
self.add_module("time", Time(self, "smartlife.iot.common.timesetting"))
|
||||
self.add_module("emeter", Emeter(self, self.emeter_type))
|
||||
self.add_module("countdown", Countdown(self, "countdown"))
|
||||
self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud"))
|
||||
|
||||
async def _initialize_features(self):
|
||||
await super()._initialize_features()
|
||||
|
||||
if bool(self.sys_info["is_dimmable"]): # pragma: no branch
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="brightness",
|
||||
name="Brightness",
|
||||
attribute_getter="brightness",
|
||||
attribute_setter="set_brightness",
|
||||
minimum_value=1,
|
||||
maximum_value=100,
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
|
||||
if self.is_variable_color_temp:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="color_temperature",
|
||||
name="Color temperature",
|
||||
container=self,
|
||||
attribute_getter="color_temp",
|
||||
attribute_setter="set_color_temp",
|
||||
range_getter="valid_temperature_range",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Type.Number,
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules not added in init."""
|
||||
await super()._initialize_modules()
|
||||
self.add_module(
|
||||
Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule")
|
||||
)
|
||||
self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule"))
|
||||
self.add_module(
|
||||
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
|
||||
)
|
||||
self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting"))
|
||||
self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
|
||||
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
|
||||
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
|
||||
self.add_module(Module.Light, Light(self, "light"))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_color(self) -> bool:
|
||||
def _is_color(self) -> bool:
|
||||
"""Whether the bulb supports color changes."""
|
||||
sys_info = self.sys_info
|
||||
return bool(sys_info["is_color"])
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_dimmable(self) -> bool:
|
||||
def _is_dimmable(self) -> bool:
|
||||
"""Whether the bulb supports brightness changes."""
|
||||
sys_info = self.sys_info
|
||||
return bool(sys_info["is_dimmable"])
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
def _is_variable_color_temp(self) -> bool:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
sys_info = self.sys_info
|
||||
return bool(sys_info["is_variable_color_temp"])
|
||||
@ -267,7 +252,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
|
||||
:return: White temperature range in Kelvin (minimum, maximum)
|
||||
"""
|
||||
if not self.is_variable_color_temp:
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Color temperature not supported")
|
||||
|
||||
for model, temp_range in TPLINK_KELVIN.items():
|
||||
@ -367,7 +352,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
|
||||
:return: hue, saturation and value (degrees, %, %)
|
||||
"""
|
||||
if not self.is_color:
|
||||
if not self._is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
light_state = cast(dict, self.light_state)
|
||||
@ -394,7 +379,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
:param int value: value in percentage [0, 100]
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_color:
|
||||
if not self._is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
if not isinstance(hue, int) or not (0 <= hue <= 360):
|
||||
@ -421,7 +406,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
@requires_update
|
||||
def color_temp(self) -> int:
|
||||
"""Return color temperature of the device in kelvin."""
|
||||
if not self.is_variable_color_temp:
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
light_state = self.light_state
|
||||
@ -436,7 +421,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
:param int temp: The new color temperature, in Kelvin
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_variable_color_temp:
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
valid_temperature_range = self.valid_temperature_range
|
||||
@ -453,11 +438,15 @@ class IotBulb(IotDevice, Bulb):
|
||||
|
||||
return await self.set_light_state(light_state, transition=transition)
|
||||
|
||||
def _raise_for_invalid_brightness(self, value):
|
||||
if not isinstance(value, int) or not (0 <= value <= 100):
|
||||
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def brightness(self) -> int:
|
||||
"""Return the current brightness in percentage."""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
if not self._is_dimmable: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
light_state = self.light_state
|
||||
@ -472,7 +461,7 @@ class IotBulb(IotDevice, Bulb):
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
if not self._is_dimmable: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
self._raise_for_invalid_brightness(brightness)
|
||||
@ -518,11 +507,11 @@ class IotBulb(IotDevice, Bulb):
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def presets(self) -> list[BulbPreset]:
|
||||
def presets(self) -> list[LightPreset]:
|
||||
"""Return a list of available bulb setting presets."""
|
||||
return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]]
|
||||
return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]]
|
||||
|
||||
async def save_preset(self, preset: BulbPreset):
|
||||
async def save_preset(self, preset: LightPreset):
|
||||
"""Save a setting preset.
|
||||
|
||||
You can either construct a preset object manually, or pass an existing one
|
||||
|
@ -19,18 +19,18 @@ import functools
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Mapping, Sequence, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
|
||||
|
||||
from ..device import Device, WifiNetwork
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..emeterstatus import EmeterStatus
|
||||
from ..exceptions import KasaException
|
||||
from ..feature import Feature
|
||||
from ..firmware import Firmware
|
||||
from ..module import ModuleT
|
||||
from ..module import Module
|
||||
from ..modulemapping import ModuleMapping, ModuleName
|
||||
from ..protocol import BaseProtocol
|
||||
from .iotmodule import IotModule
|
||||
from .modules import Emeter, Time
|
||||
from .modules import Emeter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -191,7 +191,7 @@ class IotDevice(Device):
|
||||
self._supported_modules: dict[str, IotModule] | None = None
|
||||
self._legacy_features: set[str] = set()
|
||||
self._children: Mapping[str, IotDevice] = {}
|
||||
self._modules: dict[str, IotModule] = {}
|
||||
self._modules: dict[str | ModuleName[Module], IotModule] = {}
|
||||
|
||||
@property
|
||||
def children(self) -> Sequence[IotDevice]:
|
||||
@ -199,38 +199,20 @@ class IotDevice(Device):
|
||||
return list(self._children.values())
|
||||
|
||||
@property
|
||||
def modules(self) -> dict[str, IotModule]:
|
||||
def modules(self) -> ModuleMapping[IotModule]:
|
||||
"""Return the device modules."""
|
||||
if TYPE_CHECKING:
|
||||
return cast(ModuleMapping[IotModule], self._modules)
|
||||
return self._modules
|
||||
|
||||
@overload
|
||||
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
|
||||
|
||||
@overload
|
||||
def get_module(self, module_type: str) -> IotModule | None: ...
|
||||
|
||||
def get_module(
|
||||
self, module_type: type[ModuleT] | str
|
||||
) -> ModuleT | IotModule | None:
|
||||
"""Return the module from the device modules or None if not present."""
|
||||
if isinstance(module_type, str):
|
||||
module_name = module_type.lower()
|
||||
elif issubclass(module_type, IotModule):
|
||||
module_name = module_type.__name__.lower()
|
||||
else:
|
||||
return None
|
||||
if module_name in self.modules:
|
||||
return self.modules[module_name]
|
||||
return None
|
||||
|
||||
def add_module(self, name: str, module: IotModule):
|
||||
def add_module(self, name: str | ModuleName[Module], module: IotModule):
|
||||
"""Register a module."""
|
||||
if name in self.modules:
|
||||
_LOGGER.debug("Module %s already registered, ignoring..." % name)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Adding module %s", module)
|
||||
self.modules[name] = module
|
||||
self._modules[name] = module
|
||||
|
||||
def _create_request(
|
||||
self, target: str, cmd: str, arg: dict | None = None, child_ids=None
|
||||
@ -292,11 +274,11 @@ class IotDevice(Device):
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def supported_modules(self) -> list[str]:
|
||||
def supported_modules(self) -> list[str | ModuleName[Module]]:
|
||||
"""Return a set of modules supported by the device."""
|
||||
# TODO: this should rather be called `features`, but we don't want to break
|
||||
# the API now. Maybe just deprecate it and point the users to use this?
|
||||
return list(self.modules.keys())
|
||||
return list(self._modules.keys())
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
@ -325,12 +307,19 @@ class IotDevice(Device):
|
||||
self._last_update = response
|
||||
self._set_sys_info(response["system"]["get_sysinfo"])
|
||||
|
||||
if not self._modules:
|
||||
await self._initialize_modules()
|
||||
|
||||
await self._modular_update(req)
|
||||
|
||||
if not self._features:
|
||||
await self._initialize_features()
|
||||
|
||||
await self._modular_update(req)
|
||||
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
||||
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules not added in init."""
|
||||
|
||||
async def _initialize_features(self):
|
||||
self._add_feature(
|
||||
Feature(
|
||||
@ -353,29 +342,32 @@ class IotDevice(Device):
|
||||
)
|
||||
)
|
||||
|
||||
for module in self._modules.values():
|
||||
module._initialize_features()
|
||||
for module_feat in module._module_features.values():
|
||||
self._add_feature(module_feat)
|
||||
|
||||
async def _modular_update(self, req: dict) -> None:
|
||||
"""Execute an update query."""
|
||||
if self.has_emeter:
|
||||
_LOGGER.debug(
|
||||
"The device has emeter, querying its information along sysinfo"
|
||||
)
|
||||
self.add_module("emeter", Emeter(self, self.emeter_type))
|
||||
self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
|
||||
|
||||
# TODO: perhaps modules should not have unsupported modules,
|
||||
# making separate handling for this unnecessary
|
||||
if self._supported_modules is None:
|
||||
supported = {}
|
||||
for module in self.modules.values():
|
||||
for module in self._modules.values():
|
||||
if module.is_supported:
|
||||
supported[module._module] = module
|
||||
for module_feat in module._module_features.values():
|
||||
self._add_feature(module_feat)
|
||||
|
||||
self._supported_modules = supported
|
||||
|
||||
request_list = []
|
||||
est_response_size = 1024 if "system" in req else 0
|
||||
for module in self.modules.values():
|
||||
for module in self._modules.values():
|
||||
if not module.is_supported:
|
||||
_LOGGER.debug("Module %s not supported, skipping" % module)
|
||||
continue
|
||||
@ -454,27 +446,27 @@ class IotDevice(Device):
|
||||
@requires_update
|
||||
def time(self) -> datetime:
|
||||
"""Return current time from the device."""
|
||||
return cast(Time, self.modules["time"]).time
|
||||
return self.modules[Module.IotTime].time
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def timezone(self) -> dict:
|
||||
"""Return the current timezone."""
|
||||
return cast(Time, self.modules["time"]).timezone
|
||||
return self.modules[Module.IotTime].timezone
|
||||
|
||||
async def get_time(self) -> datetime | None:
|
||||
"""Return current time from the device, if available."""
|
||||
_LOGGER.warning(
|
||||
"Use `time` property instead, this call will be removed in the future."
|
||||
)
|
||||
return await cast(Time, self.modules["time"]).get_time()
|
||||
return await self.modules[Module.IotTime].get_time()
|
||||
|
||||
async def get_timezone(self) -> dict:
|
||||
"""Return timezone information."""
|
||||
_LOGGER.warning(
|
||||
"Use `timezone` property instead, this call will be removed in the future."
|
||||
)
|
||||
return await cast(Time, self.modules["time"]).get_timezone()
|
||||
return await self.modules[Module.IotTime].get_timezone()
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
@ -555,26 +547,26 @@ class IotDevice(Device):
|
||||
def emeter_realtime(self) -> EmeterStatus:
|
||||
"""Return current energy readings."""
|
||||
self._verify_emeter()
|
||||
return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime)
|
||||
return EmeterStatus(self.modules[Module.IotEmeter].realtime)
|
||||
|
||||
async def get_emeter_realtime(self) -> EmeterStatus:
|
||||
"""Retrieve current energy readings."""
|
||||
self._verify_emeter()
|
||||
return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime())
|
||||
return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime())
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def emeter_today(self) -> float | None:
|
||||
"""Return today's energy consumption in kWh."""
|
||||
self._verify_emeter()
|
||||
return cast(Emeter, self.modules["emeter"]).emeter_today
|
||||
return self.modules[Module.IotEmeter].emeter_today
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def emeter_this_month(self) -> float | None:
|
||||
"""Return this month's energy consumption in kWh."""
|
||||
self._verify_emeter()
|
||||
return cast(Emeter, self.modules["emeter"]).emeter_this_month
|
||||
return self.modules[Module.IotEmeter].emeter_this_month
|
||||
|
||||
async def get_emeter_daily(
|
||||
self, year: int | None = None, month: int | None = None, kwh: bool = True
|
||||
@ -588,7 +580,7 @@ class IotDevice(Device):
|
||||
:return: mapping of day of month to value
|
||||
"""
|
||||
self._verify_emeter()
|
||||
return await cast(Emeter, self.modules["emeter"]).get_daystat(
|
||||
return await self.modules[Module.IotEmeter].get_daystat(
|
||||
year=year, month=month, kwh=kwh
|
||||
)
|
||||
|
||||
@ -603,15 +595,13 @@ class IotDevice(Device):
|
||||
:return: dict: mapping of month to value
|
||||
"""
|
||||
self._verify_emeter()
|
||||
return await cast(Emeter, self.modules["emeter"]).get_monthstat(
|
||||
year=year, kwh=kwh
|
||||
)
|
||||
return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh)
|
||||
|
||||
@requires_update
|
||||
async def erase_emeter_stats(self) -> dict:
|
||||
"""Erase energy meter statistics."""
|
||||
self._verify_emeter()
|
||||
return await cast(Emeter, self.modules["emeter"]).erase_stats()
|
||||
return await self.modules[Module.IotEmeter].erase_stats()
|
||||
|
||||
@requires_update
|
||||
async def current_consumption(self) -> float:
|
||||
@ -716,9 +706,3 @@ class IotDevice(Device):
|
||||
This should only be used for debugging purposes.
|
||||
"""
|
||||
return self._last_update or self._discovery_info
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def firmware(self) -> Firmware:
|
||||
"""Returns object implementing the firmware handling."""
|
||||
return cast(Firmware, self.modules["cloud"])
|
||||
|
@ -7,11 +7,11 @@ from typing import Any
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..feature import Feature
|
||||
from ..module import Module
|
||||
from ..protocol import BaseProtocol
|
||||
from .iotdevice import KasaException, requires_update
|
||||
from .iotplug import IotPlug
|
||||
from .modules import AmbientLight, Motion
|
||||
from .modules import AmbientLight, Light, Motion
|
||||
|
||||
|
||||
class ButtonAction(Enum):
|
||||
@ -79,29 +79,15 @@ class IotDimmer(IotPlug):
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Dimmer
|
||||
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules."""
|
||||
await super()._initialize_modules()
|
||||
# TODO: need to be verified if it's okay to call these on HS220 w/o these
|
||||
# TODO: need to be figured out what's the best approach to detect support
|
||||
self.add_module("motion", Motion(self, "smartlife.iot.PIR"))
|
||||
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS"))
|
||||
|
||||
async def _initialize_features(self):
|
||||
await super()._initialize_features()
|
||||
|
||||
if "brightness" in self.sys_info: # pragma: no branch
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="brightness",
|
||||
name="Brightness",
|
||||
attribute_getter="brightness",
|
||||
attribute_setter="set_brightness",
|
||||
minimum_value=1,
|
||||
maximum_value=100,
|
||||
unit="%",
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
|
||||
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
|
||||
self.add_module(Module.Light, Light(self, "light"))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
@ -110,7 +96,7 @@ class IotDimmer(IotPlug):
|
||||
|
||||
Will return a range between 0 - 100.
|
||||
"""
|
||||
if not self.is_dimmable:
|
||||
if not self._is_dimmable:
|
||||
raise KasaException("Device is not dimmable.")
|
||||
|
||||
sys_info = self.sys_info
|
||||
@ -123,7 +109,7 @@ class IotDimmer(IotPlug):
|
||||
:param int transition: transition duration in milliseconds.
|
||||
Using a transition will cause the dimmer to turn on.
|
||||
"""
|
||||
if not self.is_dimmable:
|
||||
if not self._is_dimmable:
|
||||
raise KasaException("Device is not dimmable.")
|
||||
|
||||
if not isinstance(brightness, int):
|
||||
@ -232,7 +218,7 @@ class IotDimmer(IotPlug):
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_dimmable(self) -> bool:
|
||||
def _is_dimmable(self) -> bool:
|
||||
"""Whether the switch supports brightness changes."""
|
||||
sys_info = self.sys_info
|
||||
return "brightness" in sys_info
|
||||
|
@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
|
||||
from ..module import Module
|
||||
from ..protocol import BaseProtocol
|
||||
from .effects import EFFECT_NAMES_V1
|
||||
from .iotbulb import IotBulb
|
||||
from .iotdevice import KasaException, requires_update
|
||||
from .modules.lighteffect import LightEffect
|
||||
|
||||
|
||||
class IotLightStrip(IotBulb):
|
||||
@ -55,6 +57,14 @@ class IotLightStrip(IotBulb):
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.LightStrip
|
||||
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules not added in init."""
|
||||
await super()._initialize_modules()
|
||||
self.add_module(
|
||||
Module.LightEffect,
|
||||
LightEffect(self, "smartlife.iot.lighting_effect"),
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def length(self) -> int:
|
||||
@ -73,6 +83,8 @@ class IotLightStrip(IotBulb):
|
||||
'id': '',
|
||||
'name': ''}
|
||||
"""
|
||||
# LightEffectModule returns the current effect name
|
||||
# so return the dict here for backwards compatibility
|
||||
return self.sys_info["lighting_effect_state"]
|
||||
|
||||
@property # type: ignore
|
||||
@ -83,6 +95,8 @@ class IotLightStrip(IotBulb):
|
||||
Example:
|
||||
['Aurora', 'Bubbling Cauldron', ...]
|
||||
"""
|
||||
# LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value
|
||||
# so return the original effect names here for backwards compatibility
|
||||
return EFFECT_NAMES_V1 if self.has_effects else None
|
||||
|
||||
@requires_update
|
||||
@ -105,15 +119,9 @@ class IotLightStrip(IotBulb):
|
||||
:param int brightness: The wanted brightness
|
||||
:param int transition: The wanted transition time
|
||||
"""
|
||||
if effect not in EFFECT_MAPPING_V1:
|
||||
raise KasaException(f"The effect {effect} is not a built in effect.")
|
||||
effect_dict = EFFECT_MAPPING_V1[effect]
|
||||
if brightness is not None:
|
||||
effect_dict["brightness"] = brightness
|
||||
if transition is not None:
|
||||
effect_dict["transition"] = transition
|
||||
|
||||
await self.set_custom_effect(effect_dict)
|
||||
await self.modules[Module.LightEffect].set_effect(
|
||||
effect, brightness=brightness, transition=transition
|
||||
)
|
||||
|
||||
@requires_update
|
||||
async def set_custom_effect(
|
||||
@ -126,8 +134,4 @@ class IotLightStrip(IotBulb):
|
||||
"""
|
||||
if not self.has_effects:
|
||||
raise KasaException("Bulb does not support effects.")
|
||||
await self._query_helper(
|
||||
"smartlife.iot.lighting_effect",
|
||||
"set_lighting_effect",
|
||||
effect_dict,
|
||||
)
|
||||
await self.modules[Module.LightEffect].set_custom_effect(effect_dict)
|
||||
|
@ -43,13 +43,19 @@ class IotModule(Module):
|
||||
@property
|
||||
def data(self):
|
||||
"""Return the module specific raw data from the last update."""
|
||||
if self._module not in self._device._last_update:
|
||||
dev = self._device
|
||||
q = self.query()
|
||||
|
||||
if not q:
|
||||
return dev.sys_info
|
||||
|
||||
if self._module not in dev._last_update:
|
||||
raise KasaException(
|
||||
f"You need to call update() prior accessing module data"
|
||||
f" for '{self._module}'"
|
||||
)
|
||||
|
||||
return self._device._last_update[self._module]
|
||||
return dev._last_update[self._module]
|
||||
|
||||
@property
|
||||
def is_supported(self) -> bool:
|
||||
|
@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..feature import Feature
|
||||
from ..module import Module
|
||||
from ..protocol import BaseProtocol
|
||||
from .iotdevice import IotDevice, requires_update
|
||||
from .modules import Antitheft, Cloud, Schedule, Time, Usage
|
||||
from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -53,26 +53,16 @@ class IotPlug(IotDevice):
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Plug
|
||||
self.add_module("schedule", Schedule(self, "schedule"))
|
||||
self.add_module("usage", Usage(self, "schedule"))
|
||||
self.add_module("antitheft", Antitheft(self, "anti_theft"))
|
||||
self.add_module("time", Time(self, "time"))
|
||||
self.add_module("cloud", Cloud(self, "cnCloud"))
|
||||
|
||||
async def _initialize_features(self):
|
||||
await super()._initialize_features()
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="led",
|
||||
name="LED",
|
||||
icon="mdi:led-{state}",
|
||||
attribute_getter="led",
|
||||
attribute_setter="set_led",
|
||||
type=Feature.Type.Switch,
|
||||
)
|
||||
)
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules."""
|
||||
await super()._initialize_modules()
|
||||
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
||||
self.add_module(Module.IotUsage, Usage(self, "schedule"))
|
||||
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
||||
self.add_module(Module.IotTime, Time(self, "time"))
|
||||
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
|
||||
self.add_module(Module.Led, Led(self, "system"))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
@ -93,14 +83,11 @@ class IotPlug(IotDevice):
|
||||
@requires_update
|
||||
def led(self) -> bool:
|
||||
"""Return the state of the led."""
|
||||
sys_info = self.sys_info
|
||||
return bool(1 - sys_info["led_off"])
|
||||
return self.modules[Module.Led].led
|
||||
|
||||
async def set_led(self, state: bool):
|
||||
"""Set the state of the led (night mode)."""
|
||||
return await self._query_helper(
|
||||
"system", "set_led_off", {"off": int(not state)}
|
||||
)
|
||||
return await self.modules[Module.Led].set_led(state)
|
||||
|
||||
|
||||
class IotWallSwitch(IotPlug):
|
||||
|
@ -10,6 +10,7 @@ from typing import Any
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import KasaException
|
||||
from ..module import Module
|
||||
from ..protocol import BaseProtocol
|
||||
from .iotdevice import (
|
||||
EmeterStatus,
|
||||
@ -95,11 +96,11 @@ class IotStrip(IotDevice):
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self.emeter_type = "emeter"
|
||||
self._device_type = DeviceType.Strip
|
||||
self.add_module("antitheft", Antitheft(self, "anti_theft"))
|
||||
self.add_module("schedule", Schedule(self, "schedule"))
|
||||
self.add_module("usage", Usage(self, "schedule"))
|
||||
self.add_module("time", Time(self, "time"))
|
||||
self.add_module("countdown", Countdown(self, "countdown"))
|
||||
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
||||
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
||||
self.add_module(Module.IotUsage, Usage(self, "schedule"))
|
||||
self.add_module(Module.IotTime, Time(self, "time"))
|
||||
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
@ -253,8 +254,11 @@ class IotStripPlug(IotPlug):
|
||||
self._last_update = parent._last_update
|
||||
self._set_sys_info(parent.sys_info)
|
||||
self._device_type = DeviceType.StripSocket
|
||||
self._modules = {}
|
||||
self.protocol = parent.protocol # Must use the same connection as the parent
|
||||
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules not added in init."""
|
||||
await super()._initialize_modules()
|
||||
self.add_module("time", Time(self, "time"))
|
||||
|
||||
async def update(self, update_children: bool = True):
|
||||
|
@ -5,6 +5,9 @@ from .antitheft import Antitheft
|
||||
from .cloud import Cloud
|
||||
from .countdown import Countdown
|
||||
from .emeter import Emeter
|
||||
from .led import Led
|
||||
from .light import Light
|
||||
from .lighteffect import LightEffect
|
||||
from .motion import Motion
|
||||
from .rulemodule import Rule, RuleModule
|
||||
from .schedule import Schedule
|
||||
@ -17,6 +20,9 @@ __all__ = [
|
||||
"Cloud",
|
||||
"Countdown",
|
||||
"Emeter",
|
||||
"Led",
|
||||
"Light",
|
||||
"LightEffect",
|
||||
"Motion",
|
||||
"Rule",
|
||||
"RuleModule",
|
||||
|
@ -4,19 +4,22 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from typing import Callable, Coroutine, Optional
|
||||
|
||||
from pydantic.v1 import BaseModel, Field, validator
|
||||
|
||||
from ...feature import Feature
|
||||
from ...firmware import (
|
||||
from ...interfaces.firmware import (
|
||||
Firmware,
|
||||
UpdateResult,
|
||||
)
|
||||
from ...firmware import (
|
||||
FirmwareUpdate as FirmwareUpdateInterface,
|
||||
from ...interfaces.firmware import (
|
||||
FirmwareDownloadState as FirmwareDownloadStateInterface,
|
||||
)
|
||||
from ..iotmodule import IotModule, merge
|
||||
from ...interfaces.firmware import (
|
||||
FirmwareUpdateInfo as FirmwareUpdateInfoInterface,
|
||||
)
|
||||
from ..iotmodule import IotModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -89,8 +92,11 @@ class Cloud(IotModule, Firmware):
|
||||
|
||||
# TODO: this is problematic, as it will fail the whole query on some
|
||||
# devices if they are not connected to the internet
|
||||
if self._module in self._device._last_update and self.is_connected:
|
||||
req = merge(req, self.get_available_firmwares())
|
||||
|
||||
# The following causes a recursion error as self.is_connected
|
||||
# accesses self.data which calls query. Also get_available_firmwares is async
|
||||
# if self._module in self._device._last_update and self.is_connected:
|
||||
# req = merge(req, self.get_available_firmwares())
|
||||
|
||||
return req
|
||||
|
||||
@ -130,7 +136,12 @@ class Cloud(IotModule, Firmware):
|
||||
"""Disconnect from the cloud."""
|
||||
return await self.call("unbind")
|
||||
|
||||
async def update_firmware(self, *, progress_cb=None) -> UpdateResult:
|
||||
async def update_firmware(
|
||||
self,
|
||||
*,
|
||||
progress_cb: Callable[[FirmwareDownloadStateInterface], Coroutine]
|
||||
| None = None,
|
||||
) -> UpdateResult:
|
||||
"""Perform firmware update."""
|
||||
raise NotImplementedError
|
||||
i = 0
|
||||
@ -144,11 +155,16 @@ class Cloud(IotModule, Firmware):
|
||||
|
||||
return UpdateResult("")
|
||||
|
||||
async def check_for_updates(self) -> FirmwareUpdateInterface:
|
||||
async def check_for_updates(self) -> FirmwareUpdateInfoInterface:
|
||||
"""Return firmware update information."""
|
||||
# TODO: naming of the common firmware API methods
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_update_state(self) -> FirmwareUpdateInfoInterface:
|
||||
"""Return firmware update information."""
|
||||
fw = await self.get_firmware_update()
|
||||
|
||||
return FirmwareUpdateInterface(
|
||||
return FirmwareUpdateInfoInterface(
|
||||
update_available=fw.update_available,
|
||||
current_version=self._device.hw_info.get("sw_ver"),
|
||||
available_version=fw.version,
|
||||
|
32
kasa/iot/modules/led.py
Normal file
32
kasa/iot/modules/led.py
Normal 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
200
kasa/iot/modules/light.py
Normal 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
|
||||
)
|
97
kasa/iot/modules/lighteffect.py
Normal file
97
kasa/iot/modules/lighteffect.py
Normal 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 {}
|
@ -6,14 +6,19 @@ import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Final,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from .exceptions import KasaException
|
||||
from .feature import Feature
|
||||
from .modulemapping import ModuleName
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import interfaces
|
||||
from .device import Device
|
||||
from .iot import modules as iot
|
||||
from .smart import modules as smart
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -27,6 +32,59 @@ class Module(ABC):
|
||||
executed during the regular update cycle.
|
||||
"""
|
||||
|
||||
# Common Modules
|
||||
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
|
||||
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
|
||||
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
|
||||
Firmware: Final[ModuleName[interfaces.Firmware]] = ModuleName("Firmware")
|
||||
|
||||
# IOT only Modules
|
||||
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
|
||||
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
|
||||
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
|
||||
IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter")
|
||||
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
|
||||
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
|
||||
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
|
||||
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
|
||||
IotTime: Final[ModuleName[iot.Time]] = ModuleName("time")
|
||||
|
||||
# SMART only Modules
|
||||
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
|
||||
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")
|
||||
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
|
||||
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")
|
||||
ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice")
|
||||
Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud")
|
||||
Color: Final[ModuleName[smart.Color]] = ModuleName("Color")
|
||||
ColorTemperature: Final[ModuleName[smart.ColorTemperature]] = ModuleName(
|
||||
"ColorTemperature"
|
||||
)
|
||||
ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor")
|
||||
DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule")
|
||||
Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy")
|
||||
Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan")
|
||||
FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName(
|
||||
"FrostProtection"
|
||||
)
|
||||
HumiditySensor: Final[ModuleName[smart.HumiditySensor]] = ModuleName(
|
||||
"HumiditySensor"
|
||||
)
|
||||
LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName(
|
||||
"LightTransition"
|
||||
)
|
||||
ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode")
|
||||
TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
|
||||
"TemperatureSensor"
|
||||
)
|
||||
TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName(
|
||||
"TemperatureControl"
|
||||
)
|
||||
Time: Final[ModuleName[smart.Time]] = ModuleName("Time")
|
||||
WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName(
|
||||
"WaterleakSensor"
|
||||
)
|
||||
|
||||
def __init__(self, device: Device, module: str):
|
||||
self._device = device
|
||||
self._module = module
|
||||
|
25
kasa/modulemapping.py
Normal file
25
kasa/modulemapping.py
Normal 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
96
kasa/modulemapping.pyi
Normal 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
|
12
kasa/plug.py
12
kasa/plug.py
@ -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."""
|
@ -1,49 +1,53 @@
|
||||
"""Modules for SMART devices."""
|
||||
|
||||
from .alarmmodule import AlarmModule
|
||||
from .autooffmodule import AutoOffModule
|
||||
from .battery import BatterySensor
|
||||
from .alarm import Alarm
|
||||
from .autooff import AutoOff
|
||||
from .batterysensor import BatterySensor
|
||||
from .brightness import Brightness
|
||||
from .childdevicemodule import ChildDeviceModule
|
||||
from .cloudmodule import CloudModule
|
||||
from .colormodule import ColorModule
|
||||
from .colortemp import ColorTemperatureModule
|
||||
from .childdevice import ChildDevice
|
||||
from .cloud import Cloud
|
||||
from .color import Color
|
||||
from .colortemperature import ColorTemperature
|
||||
from .contactsensor import ContactSensor
|
||||
from .devicemodule import DeviceModule
|
||||
from .energymodule import EnergyModule
|
||||
from .fanmodule import FanModule
|
||||
from .energy import Energy
|
||||
from .fan import Fan
|
||||
from .firmware import Firmware
|
||||
from .frostprotection import FrostProtectionModule
|
||||
from .humidity import HumiditySensor
|
||||
from .ledmodule import LedModule
|
||||
from .lighteffectmodule import LightEffectModule
|
||||
from .lighttransitionmodule import LightTransitionModule
|
||||
from .reportmodule import ReportModule
|
||||
from .temperature import TemperatureSensor
|
||||
from .frostprotection import FrostProtection
|
||||
from .humiditysensor import HumiditySensor
|
||||
from .led import Led
|
||||
from .light import Light
|
||||
from .lighteffect import LightEffect
|
||||
from .lighttransition import LightTransition
|
||||
from .reportmode import ReportMode
|
||||
from .temperaturecontrol import TemperatureControl
|
||||
from .timemodule import TimeModule
|
||||
from .waterleak import WaterleakSensor
|
||||
from .temperaturesensor import TemperatureSensor
|
||||
from .time import Time
|
||||
from .waterleaksensor import WaterleakSensor
|
||||
|
||||
__all__ = [
|
||||
"AlarmModule",
|
||||
"TimeModule",
|
||||
"EnergyModule",
|
||||
"Alarm",
|
||||
"Time",
|
||||
"Energy",
|
||||
"DeviceModule",
|
||||
"ChildDeviceModule",
|
||||
"ChildDevice",
|
||||
"BatterySensor",
|
||||
"HumiditySensor",
|
||||
"TemperatureSensor",
|
||||
"TemperatureControl",
|
||||
"ReportModule",
|
||||
"AutoOffModule",
|
||||
"LedModule",
|
||||
"ReportMode",
|
||||
"AutoOff",
|
||||
"Led",
|
||||
"Brightness",
|
||||
"FanModule",
|
||||
"Fan",
|
||||
"Firmware",
|
||||
"CloudModule",
|
||||
"LightEffectModule",
|
||||
"LightTransitionModule",
|
||||
"ColorTemperatureModule",
|
||||
"ColorModule",
|
||||
"Cloud",
|
||||
"Light",
|
||||
"LightEffect",
|
||||
"LightTransition",
|
||||
"ColorTemperature",
|
||||
"Color",
|
||||
"WaterleakSensor",
|
||||
"FrostProtectionModule",
|
||||
"ContactSensor",
|
||||
"FrostProtection",
|
||||
]
|
||||
|
@ -6,7 +6,7 @@ from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class AlarmModule(SmartModule):
|
||||
class Alarm(SmartModule):
|
||||
"""Implementation of alarm module."""
|
||||
|
||||
REQUIRED_COMPONENT = "alarm"
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class AutoOffModule(SmartModule):
|
||||
class AutoOff(SmartModule):
|
||||
"""Implementation of auto off module."""
|
||||
|
||||
REQUIRED_COMPONENT = "auto_off"
|
@ -2,14 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class BatterySensor(SmartModule):
|
||||
"""Implementation of battery module."""
|
||||
@ -17,23 +12,11 @@ class BatterySensor(SmartModule):
|
||||
REQUIRED_COMPONENT = "battery_detect"
|
||||
QUERY_GETTER_NAME = "get_battery_detect_info"
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
super().__init__(device, module)
|
||||
def _initialize_features(self):
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
"battery_level",
|
||||
"Battery level",
|
||||
container=self,
|
||||
attribute_getter="battery",
|
||||
icon="mdi:battery",
|
||||
unit="%",
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
self._device,
|
||||
"battery_low",
|
||||
"Battery low",
|
||||
container=self,
|
||||
@ -44,6 +27,22 @@ class BatterySensor(SmartModule):
|
||||
)
|
||||
)
|
||||
|
||||
# Some devices, like T110 contact sensor do not report the battery percentage
|
||||
if "battery_percentage" in self._device.sys_info:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_level",
|
||||
"Battery level",
|
||||
container=self,
|
||||
attribute_getter="battery",
|
||||
icon="mdi:battery",
|
||||
unit="%",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def battery(self):
|
||||
"""Return battery level."""
|
@ -2,16 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
BRIGHTNESS_MIN = 1
|
||||
BRIGHTNESS_MIN = 0
|
||||
BRIGHTNESS_MAX = 100
|
||||
|
||||
|
||||
@ -20,8 +14,11 @@ class Brightness(SmartModule):
|
||||
|
||||
REQUIRED_COMPONENT = "brightness"
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
super().__init__(device, module)
|
||||
def _initialize_features(self):
|
||||
"""Initialize features."""
|
||||
super()._initialize_features()
|
||||
|
||||
device = self._device
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
@ -47,8 +44,11 @@ class Brightness(SmartModule):
|
||||
"""Return current brightness."""
|
||||
return self.data["brightness"]
|
||||
|
||||
async def set_brightness(self, brightness: int):
|
||||
"""Set the brightness."""
|
||||
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
||||
"""Set the brightness. A brightness value of 0 will turn off the light.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
"""
|
||||
if not isinstance(brightness, int) or not (
|
||||
BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX
|
||||
):
|
||||
@ -57,6 +57,8 @@ class Brightness(SmartModule):
|
||||
f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)"
|
||||
)
|
||||
|
||||
if brightness == 0:
|
||||
return await self._device.turn_off()
|
||||
return await self.call("set_device_info", {"brightness": brightness})
|
||||
|
||||
async def _check_supported(self):
|
||||
|
@ -3,7 +3,7 @@
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class ChildDeviceModule(SmartModule):
|
||||
class ChildDevice(SmartModule):
|
||||
"""Implementation for child devices."""
|
||||
|
||||
REQUIRED_COMPONENT = "child_device"
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class CloudModule(SmartModule):
|
||||
class Cloud(SmartModule):
|
||||
"""Implementation of cloud module."""
|
||||
|
||||
QUERY_GETTER_NAME = "get_connect_cloud_state"
|
@ -4,15 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...bulb import HSV
|
||||
from ...feature import Feature
|
||||
from ...interfaces.light import HSV
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class ColorModule(SmartModule):
|
||||
class Color(SmartModule):
|
||||
"""Implementation of color module."""
|
||||
|
||||
REQUIRED_COMPONENT = "color"
|
@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...bulb import ColorTempRange
|
||||
from ...feature import Feature
|
||||
from ...interfaces.light import ColorTempRange
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DEFAULT_TEMP_RANGE = [2500, 6500]
|
||||
|
||||
|
||||
class ColorTemperatureModule(SmartModule):
|
||||
class ColorTemperature(SmartModule):
|
||||
"""Implementation of color temp module."""
|
||||
|
||||
REQUIRED_COMPONENT = "color_temperature"
|
42
kasa/smart/modules/contactsensor.py
Normal file
42
kasa/smart/modules/contactsensor.py
Normal 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"]
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class EnergyModule(SmartModule):
|
||||
class Energy(SmartModule):
|
||||
"""Implementation of energy monitoring module."""
|
||||
|
||||
REQUIRED_COMPONENT = "energy_monitoring"
|
@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class FanModule(SmartModule):
|
||||
class Fan(SmartModule):
|
||||
"""Implementation of fan_control module."""
|
||||
|
||||
REQUIRED_COMPONENT = "fan_control"
|
@ -5,10 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional
|
||||
|
||||
# When support for cpython older than 3.11 is dropped
|
||||
# async_timeout can be replaced with asyncio.timeout
|
||||
# When support for cpython older than 3.11 is dropped
|
||||
# async_timeout can be replaced with asyncio.timeout
|
||||
from async_timeout import timeout as asyncio_timeout
|
||||
@ -16,9 +14,12 @@ from pydantic.v1 import BaseModel, Field, validator
|
||||
|
||||
from ...exceptions import SmartErrorCode
|
||||
from ...feature import Feature
|
||||
from ...firmware import Firmware as FirmwareInterface
|
||||
from ...firmware import FirmwareUpdate as FirmwareUpdateInterface
|
||||
from ...firmware import UpdateResult
|
||||
from ...interfaces import Firmware as FirmwareInterface
|
||||
from ...interfaces.firmware import (
|
||||
FirmwareDownloadState as FirmwareDownloadStateInterface,
|
||||
)
|
||||
from ...interfaces.firmware import FirmwareUpdateInfo as FirmwareUpdateInfoInterface
|
||||
from ...interfaces.firmware import UpdateResult
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -28,7 +29,20 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FirmwareUpdate(BaseModel):
|
||||
class DownloadState(BaseModel):
|
||||
"""Download state."""
|
||||
|
||||
# Example:
|
||||
# {'status': 0, 'download_progress': 0, 'reboot_time': 5,
|
||||
# 'upgrade_time': 5, 'auto_upgrade': False}
|
||||
status: int
|
||||
progress: int = Field(alias="download_progress")
|
||||
reboot_time: int
|
||||
upgrade_time: int
|
||||
auto_upgrade: bool
|
||||
|
||||
|
||||
class FirmwareUpdateInfo(BaseModel):
|
||||
"""Update info status object."""
|
||||
|
||||
status: int = Field(alias="type")
|
||||
@ -91,7 +105,7 @@ class Firmware(SmartModule, FirmwareInterface):
|
||||
name="Current firmware version",
|
||||
container=self,
|
||||
attribute_getter="current_firmware",
|
||||
category=Feature.Category.Info,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
@ -101,7 +115,7 @@ class Firmware(SmartModule, FirmwareInterface):
|
||||
name="Available firmware version",
|
||||
container=self,
|
||||
attribute_getter="latest_firmware",
|
||||
category=Feature.Category.Info,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
@ -128,9 +142,9 @@ class Firmware(SmartModule, FirmwareInterface):
|
||||
fw = self.data.get("get_latest_fw") or self.data
|
||||
if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode):
|
||||
# Error in response, probably disconnected from the cloud.
|
||||
return FirmwareUpdate(type=0, need_to_upgrade=False)
|
||||
return FirmwareUpdateInfo(type=0, need_to_upgrade=False)
|
||||
|
||||
return FirmwareUpdate.parse_obj(fw)
|
||||
return FirmwareUpdateInfo.parse_obj(fw)
|
||||
|
||||
@property
|
||||
def update_available(self) -> bool | None:
|
||||
@ -139,31 +153,60 @@ class Firmware(SmartModule, FirmwareInterface):
|
||||
return None
|
||||
return self.firmware_update_info.update_available
|
||||
|
||||
async def get_update_state(self):
|
||||
async def get_update_state(self) -> DownloadState:
|
||||
"""Return update state."""
|
||||
return await self.call("get_fw_download_state")
|
||||
resp = await self.call("get_fw_download_state")
|
||||
state = resp["get_fw_download_state"]
|
||||
return DownloadState(**state)
|
||||
|
||||
async def update(self):
|
||||
async def update(
|
||||
self, progress_cb: Callable[[DownloadState], Coroutine] | None = None
|
||||
):
|
||||
"""Update the device firmware."""
|
||||
current_fw = self.current_firmware
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
"Going to upgrade from %s to %s",
|
||||
current_fw,
|
||||
self.firmware_update_info.version,
|
||||
)
|
||||
resp = await self.call("fw_download")
|
||||
_LOGGER.debug("Update request response: %s", resp)
|
||||
await self.call("fw_download")
|
||||
|
||||
# TODO: read timeout from get_auto_update_info or from get_fw_download_state?
|
||||
async with asyncio_timeout(60 * 5):
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
state = await self.get_update_state()
|
||||
_LOGGER.debug("Update state: %s" % 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.info("Updated to %s", self.firmware_update_info.version)
|
||||
_LOGGER.debug("Update state: %s" % state)
|
||||
if progress_cb is not None:
|
||||
asyncio.create_task(progress_cb(state))
|
||||
|
||||
if state.status == 0:
|
||||
_LOGGER.info(
|
||||
"Update idle, hopefully updated to %s",
|
||||
self.firmware_update_info.version,
|
||||
)
|
||||
break
|
||||
elif state.status == 2:
|
||||
_LOGGER.info("Downloading firmware, progress: %s", state.progress)
|
||||
elif state.status == 3:
|
||||
upgrade_sleep = state.upgrade_time
|
||||
_LOGGER.info(
|
||||
"Flashing firmware, sleeping for %s before checking status",
|
||||
upgrade_sleep,
|
||||
)
|
||||
await asyncio.sleep(upgrade_sleep)
|
||||
elif state.status < 0:
|
||||
_LOGGER.error("Got error: %s", state.status)
|
||||
break
|
||||
else:
|
||||
_LOGGER.warning("Unhandled state code: %s", state)
|
||||
|
||||
@property
|
||||
def auto_update_enabled(self):
|
||||
@ -178,16 +221,21 @@ class Firmware(SmartModule, FirmwareInterface):
|
||||
data = {**self.data["get_auto_update_info"], "enable": enabled}
|
||||
await self.call("set_auto_update_info", data)
|
||||
|
||||
async def update_firmware(self, *, progress_cb) -> UpdateResult:
|
||||
async def update_firmware(
|
||||
self,
|
||||
*,
|
||||
progress_cb: Callable[[FirmwareDownloadStateInterface], Coroutine]
|
||||
| None = None,
|
||||
) -> UpdateResult:
|
||||
"""Update the firmware."""
|
||||
# TODO: implement, this is part of the common firmware API
|
||||
raise NotImplementedError
|
||||
|
||||
async def check_for_updates(self) -> FirmwareUpdateInterface:
|
||||
async def check_for_updates(self) -> FirmwareUpdateInfoInterface:
|
||||
"""Return firmware update information."""
|
||||
# TODO: naming of the common firmware API methods
|
||||
info = self.firmware_update_info
|
||||
return FirmwareUpdateInterface(
|
||||
return FirmwareUpdateInfoInterface(
|
||||
current_version=self.current_firmware,
|
||||
update_available=info.update_available,
|
||||
available_version=info.version,
|
||||
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class FrostProtectionModule(SmartModule):
|
||||
class FrostProtection(SmartModule):
|
||||
"""Implementation for frost protection module.
|
||||
|
||||
This basically turns the thermostat on and off.
|
||||
|
@ -2,37 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces.led import Led as LedInterface
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class LedModule(SmartModule):
|
||||
class Led(SmartModule, LedInterface):
|
||||
"""Implementation of led controls."""
|
||||
|
||||
REQUIRED_COMPONENT = "led"
|
||||
QUERY_GETTER_NAME = "get_led_info"
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
super().__init__(device, module)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=device,
|
||||
container=self,
|
||||
id="led",
|
||||
name="LED",
|
||||
icon="mdi:led-{state}",
|
||||
attribute_getter="led",
|
||||
attribute_setter="set_led",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {self.QUERY_GETTER_NAME: {"led_rule": None}}
|
||||
@ -56,7 +35,7 @@ class LedModule(SmartModule):
|
||||
This should probably be a select with always/never/nightmode.
|
||||
"""
|
||||
rule = "always" if enable else "never"
|
||||
return await self.call("set_led_info", self.data | {"led_rule": rule})
|
||||
return await self.call("set_led_info", dict(self.data, **{"led_rule": rule}))
|
||||
|
||||
@property
|
||||
def night_mode_settings(self):
|
126
kasa/smart/modules/light.py
Normal file
126
kasa/smart/modules/light.py
Normal 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
|
@ -6,14 +6,14 @@ import base64
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class LightEffectModule(SmartModule):
|
||||
class LightEffect(SmartModule, LightEffectInterface):
|
||||
"""Implementation of dynamic light effects."""
|
||||
|
||||
REQUIRED_COMPONENT = "light_effect"
|
||||
@ -22,29 +22,11 @@ class LightEffectModule(SmartModule):
|
||||
"L1": "Party",
|
||||
"L2": "Relax",
|
||||
}
|
||||
LIGHT_EFFECTS_OFF = "Off"
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
super().__init__(device, module)
|
||||
self._scenes_names_to_id: dict[str, str] = {}
|
||||
|
||||
def _initialize_features(self):
|
||||
"""Initialize features."""
|
||||
device = self._device
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
id="light_effect",
|
||||
name="Light effect",
|
||||
container=self,
|
||||
attribute_getter="effect",
|
||||
attribute_setter="set_effect",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Choice,
|
||||
choices_getter="effect_list",
|
||||
)
|
||||
)
|
||||
|
||||
def _initialize_effects(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return built-in effects."""
|
||||
# Copy the effects so scene name updates do not update the underlying dict.
|
||||
@ -64,7 +46,7 @@ class LightEffectModule(SmartModule):
|
||||
return effects
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str] | None:
|
||||
def effect_list(self) -> list[str]:
|
||||
"""Return built-in effects list.
|
||||
|
||||
Example:
|
||||
@ -90,6 +72,9 @@ class LightEffectModule(SmartModule):
|
||||
async def set_effect(
|
||||
self,
|
||||
effect: str,
|
||||
*,
|
||||
brightness: int | None = None,
|
||||
transition: int | None = None,
|
||||
) -> None:
|
||||
"""Set an effect for the device.
|
||||
|
||||
@ -108,6 +93,24 @@ class LightEffectModule(SmartModule):
|
||||
params["id"] = effect_id
|
||||
return await self.call("set_dynamic_light_effect_rule_enable", params)
|
||||
|
||||
async def set_custom_effect(
|
||||
self,
|
||||
effect_dict: dict,
|
||||
) -> None:
|
||||
"""Set a custom effect on the device.
|
||||
|
||||
:param str effect_dict: The custom effect dict to set
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Device does not support setting custom effects. "
|
||||
"Use has_custom_effects to check for support."
|
||||
)
|
||||
|
||||
@property
|
||||
def has_custom_effects(self) -> bool:
|
||||
"""Return True if the device supports setting custom effects."""
|
||||
return False
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {self.QUERY_GETTER_NAME: {"start_index": 0}}
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class LightTransitionModule(SmartModule):
|
||||
class LightTransition(SmartModule):
|
||||
"""Implementation of gradual on/off."""
|
||||
|
||||
REQUIRED_COMPONENT = "on_off_gradually"
|
@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class ReportModule(SmartModule):
|
||||
class ReportMode(SmartModule):
|
||||
"""Implementation of report module."""
|
||||
|
||||
REQUIRED_COMPONENT = "report_mode"
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class TimeModule(SmartModule):
|
||||
class Time(SmartModule):
|
||||
"""Implementation of device_local_time."""
|
||||
|
||||
REQUIRED_COMPONENT = "time"
|
@ -49,6 +49,7 @@ class SmartChildDevice(SmartDevice):
|
||||
"""Return child device type."""
|
||||
child_device_map = {
|
||||
"plug.powerstrip.sub-plug": DeviceType.Plug,
|
||||
"subg.trigger.contact-sensor": DeviceType.Sensor,
|
||||
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
|
||||
"subg.trigger.water-leak-sensor": DeviceType.Sensor,
|
||||
"kasa.switch.outlet.sub-fan": DeviceType.Fan,
|
||||
|
@ -5,32 +5,24 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Mapping, Sequence, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
|
||||
|
||||
from ..aestransport import AesTransport
|
||||
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
|
||||
from ..device import Device, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..emeterstatus import EmeterStatus
|
||||
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
||||
from ..fan import Fan
|
||||
from ..feature import Feature
|
||||
from ..firmware import Firmware
|
||||
from ..module import ModuleT
|
||||
from ..module import Module
|
||||
from ..modulemapping import ModuleMapping, ModuleName
|
||||
from ..smartprotocol import SmartProtocol
|
||||
from .modules import (
|
||||
Brightness,
|
||||
CloudModule,
|
||||
ColorModule,
|
||||
ColorTemperatureModule,
|
||||
Cloud,
|
||||
DeviceModule,
|
||||
EnergyModule,
|
||||
FanModule,
|
||||
TimeModule,
|
||||
)
|
||||
from .modules import (
|
||||
Firmware as FirmwareModule,
|
||||
Firmware,
|
||||
Light,
|
||||
Time,
|
||||
)
|
||||
from .smartmodule import SmartModule
|
||||
|
||||
@ -41,12 +33,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# the child but only work on the parent. See longer note below in _initialize_modules.
|
||||
# This list should be updated when creating new modules that could have the
|
||||
# same issue, homekit perhaps?
|
||||
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule]
|
||||
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
|
||||
|
||||
|
||||
# Device must go last as the other interfaces also inherit Device
|
||||
# and python needs a consistent method resolution order.
|
||||
class SmartDevice(Bulb, Fan, Device):
|
||||
class SmartDevice(Device):
|
||||
"""Base class to represent a SMART protocol based device."""
|
||||
|
||||
def __init__(
|
||||
@ -64,7 +56,7 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
self._components_raw: dict[str, Any] | None = None
|
||||
self._components: dict[str, int] = {}
|
||||
self._state_information: dict[str, Any] = {}
|
||||
self._modules: dict[str, SmartModule] = {}
|
||||
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
||||
self._exposes_child_modules = False
|
||||
self._parent: SmartDevice | None = None
|
||||
self._children: Mapping[str, SmartDevice] = {}
|
||||
@ -105,8 +97,20 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
return list(self._children.values())
|
||||
|
||||
@property
|
||||
def modules(self) -> dict[str, SmartModule]:
|
||||
def modules(self) -> ModuleMapping[SmartModule]:
|
||||
"""Return the device modules."""
|
||||
if self._exposes_child_modules:
|
||||
modules = {k: v for k, v in self._modules.items()}
|
||||
for child in self._children.values():
|
||||
for k, v in child._modules.items():
|
||||
if k not in modules:
|
||||
modules[k] = v
|
||||
if TYPE_CHECKING:
|
||||
return cast(ModuleMapping[SmartModule], modules)
|
||||
return modules
|
||||
|
||||
if TYPE_CHECKING: # Needed for python 3.8
|
||||
return cast(ModuleMapping[SmartModule], self._modules)
|
||||
return self._modules
|
||||
|
||||
def _try_get_response(self, responses: dict, request: str, default=None) -> dict:
|
||||
@ -213,7 +217,10 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES
|
||||
) or mod.__name__ in child_modules_to_skip:
|
||||
continue
|
||||
if mod.REQUIRED_COMPONENT in self._components:
|
||||
if (
|
||||
mod.REQUIRED_COMPONENT in self._components
|
||||
or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Found required %s, adding %s to modules.",
|
||||
mod.REQUIRED_COMPONENT,
|
||||
@ -223,6 +230,13 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
if await module._check_supported():
|
||||
self._modules[module.name] = module
|
||||
|
||||
if (
|
||||
Module.Brightness in self._modules
|
||||
or Module.Color in self._modules
|
||||
or Module.ColorTemperature in self._modules
|
||||
):
|
||||
self._modules[Light.__name__] = Light(self, "light")
|
||||
|
||||
async def _initialize_features(self):
|
||||
"""Initialize device features."""
|
||||
self._add_feature(
|
||||
@ -310,41 +324,20 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
)
|
||||
)
|
||||
|
||||
for module in self._modules.values():
|
||||
for module in self.modules.values():
|
||||
# Check if module features have already been initialized.
|
||||
# i.e. when _exposes_child_modules is true
|
||||
if not module._module_features:
|
||||
module._initialize_features()
|
||||
for feat in module._module_features.values():
|
||||
self._add_feature(feat)
|
||||
|
||||
@overload
|
||||
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
|
||||
|
||||
@overload
|
||||
def get_module(self, module_type: str) -> SmartModule | None: ...
|
||||
|
||||
def get_module(
|
||||
self, module_type: type[ModuleT] | str
|
||||
) -> ModuleT | SmartModule | None:
|
||||
"""Return the module from the device modules or None if not present."""
|
||||
if isinstance(module_type, str):
|
||||
module_name = module_type
|
||||
elif issubclass(module_type, SmartModule):
|
||||
module_name = module_type.__name__
|
||||
else:
|
||||
return None
|
||||
if module_name in self.modules:
|
||||
return self.modules[module_name]
|
||||
elif self._exposes_child_modules:
|
||||
for child in self._children.values():
|
||||
if module_name in child.modules:
|
||||
return child.modules[module_name]
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_cloud_connected(self):
|
||||
def is_cloud_connected(self) -> bool:
|
||||
"""Returns if the device is connected to the cloud."""
|
||||
if "CloudModule" not in self.modules:
|
||||
if Module.Cloud not in self.modules:
|
||||
return False
|
||||
return self.modules["CloudModule"].is_connected
|
||||
return self.modules[Module.Cloud].is_connected
|
||||
|
||||
@property
|
||||
def sys_info(self) -> dict[str, Any]:
|
||||
@ -368,10 +361,10 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
def time(self) -> datetime:
|
||||
"""Return the time."""
|
||||
# TODO: Default to parent's time module for child devices
|
||||
if self._parent and "TimeModule" in self.modules:
|
||||
_timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405
|
||||
if self._parent and Module.Time in self.modules:
|
||||
_timemod = self._parent.modules[Module.Time]
|
||||
else:
|
||||
_timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405
|
||||
_timemod = self.modules[Module.Time]
|
||||
|
||||
return _timemod.time
|
||||
|
||||
@ -448,12 +441,7 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
@property
|
||||
def has_emeter(self) -> bool:
|
||||
"""Return if the device has emeter."""
|
||||
return "EnergyModule" in self.modules
|
||||
|
||||
@property
|
||||
def is_dimmer(self) -> bool:
|
||||
"""Whether the device acts as a dimmer."""
|
||||
return self.is_dimmable
|
||||
return Module.Energy in self.modules
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
@ -490,19 +478,19 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
@property
|
||||
def emeter_realtime(self) -> EmeterStatus:
|
||||
"""Get the emeter status."""
|
||||
energy = cast(EnergyModule, self.modules["EnergyModule"])
|
||||
energy = self.modules[Module.Energy]
|
||||
return energy.emeter_realtime
|
||||
|
||||
@property
|
||||
def emeter_this_month(self) -> float | None:
|
||||
"""Get the emeter value for this month."""
|
||||
energy = cast(EnergyModule, self.modules["EnergyModule"])
|
||||
energy = self.modules[Module.Energy]
|
||||
return energy.emeter_this_month
|
||||
|
||||
@property
|
||||
def emeter_today(self) -> float | None:
|
||||
"""Get the emeter value for today."""
|
||||
energy = cast(EnergyModule, self.modules["EnergyModule"])
|
||||
energy = self.modules[Module.Energy]
|
||||
return energy.emeter_today
|
||||
|
||||
@property
|
||||
@ -514,8 +502,7 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
):
|
||||
return None
|
||||
on_time = cast(float, on_time)
|
||||
if (timemod := self.modules.get("TimeModule")) is not None:
|
||||
timemod = cast(TimeModule, timemod) # noqa: F405
|
||||
if (timemod := self.modules.get(Module.Time)) is not None:
|
||||
return timemod.time - timedelta(seconds=on_time)
|
||||
else: # We have no device time, use current local time.
|
||||
return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time)
|
||||
@ -628,12 +615,6 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
|
||||
return self._device_type
|
||||
|
||||
@property
|
||||
def firmware(self) -> Firmware:
|
||||
"""Return firmware module."""
|
||||
# TODO: open question: does it make sense to expose common modules?
|
||||
return cast(Firmware, self.get_module(FirmwareModule))
|
||||
|
||||
@staticmethod
|
||||
def _get_device_type_from_components(
|
||||
components: list[str], device_type: str
|
||||
@ -661,149 +642,3 @@ class SmartDevice(Bulb, Fan, Device):
|
||||
return DeviceType.Thermostat
|
||||
_LOGGER.warning("Unknown device type, falling back to plug")
|
||||
return DeviceType.Plug
|
||||
|
||||
# Fan interface methods
|
||||
|
||||
@property
|
||||
def is_fan(self) -> bool:
|
||||
"""Return True if the device is a fan."""
|
||||
return "FanModule" in self.modules
|
||||
|
||||
@property
|
||||
def fan_speed_level(self) -> int:
|
||||
"""Return fan speed level."""
|
||||
if not self.is_fan:
|
||||
raise KasaException("Device is not a Fan")
|
||||
return cast(FanModule, self.modules["FanModule"]).fan_speed_level
|
||||
|
||||
async def set_fan_speed_level(self, level: int):
|
||||
"""Set fan speed level."""
|
||||
if not self.is_fan:
|
||||
raise KasaException("Device is not a Fan")
|
||||
await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level)
|
||||
|
||||
# Bulb interface methods
|
||||
|
||||
@property
|
||||
def is_color(self) -> bool:
|
||||
"""Whether the bulb supports color changes."""
|
||||
return "ColorModule" in self.modules
|
||||
|
||||
@property
|
||||
def is_dimmable(self) -> bool:
|
||||
"""Whether the bulb supports brightness changes."""
|
||||
return "Brightness" in self.modules
|
||||
|
||||
@property
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
return "ColorTemperatureModule" in self.modules
|
||||
|
||||
@property
|
||||
def valid_temperature_range(self) -> ColorTempRange:
|
||||
"""Return the device-specific white temperature range (in Kelvin).
|
||||
|
||||
:return: White temperature range in Kelvin (minimum, maximum)
|
||||
"""
|
||||
if not self.is_variable_color_temp:
|
||||
raise KasaException("Color temperature not supported")
|
||||
|
||||
return cast(
|
||||
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
|
||||
).valid_temperature_range
|
||||
|
||||
@property
|
||||
def hsv(self) -> HSV:
|
||||
"""Return the current HSV state of the bulb.
|
||||
|
||||
:return: hue, saturation and value (degrees, %, %)
|
||||
"""
|
||||
if not self.is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
return cast(ColorModule, self.modules["ColorModule"]).hsv
|
||||
|
||||
@property
|
||||
def color_temp(self) -> int:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
if not self.is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
return cast(
|
||||
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
|
||||
).color_temp
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the current brightness in percentage."""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
return cast(Brightness, self.modules["Brightness"]).brightness
|
||||
|
||||
async def set_hsv(
|
||||
self,
|
||||
hue: int,
|
||||
saturation: int,
|
||||
value: int | None = None,
|
||||
*,
|
||||
transition: int | None = None,
|
||||
) -> dict:
|
||||
"""Set new HSV.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
|
||||
:param int hue: hue in degrees
|
||||
:param int saturation: saturation in percentage [0,100]
|
||||
:param int value: value between 1 and 100
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
return await cast(ColorModule, self.modules["ColorModule"]).set_hsv(
|
||||
hue, saturation, value
|
||||
)
|
||||
|
||||
async def set_color_temp(
|
||||
self, temp: int, *, brightness=None, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the color temperature of the device in kelvin.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
|
||||
:param int temp: The new color temperature, in Kelvin
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
return await cast(
|
||||
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
|
||||
).set_color_temp(temp)
|
||||
|
||||
async def set_brightness(
|
||||
self, brightness: int, *, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the brightness in percentage.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
return await cast(Brightness, self.modules["Brightness"]).set_brightness(
|
||||
brightness
|
||||
)
|
||||
|
||||
@property
|
||||
def presets(self) -> list[BulbPreset]:
|
||||
"""Return a list of available bulb setting presets."""
|
||||
return []
|
||||
|
||||
@property
|
||||
def has_effects(self) -> bool:
|
||||
"""Return True if the device supports effects."""
|
||||
return "LightEffectModule" in self.modules
|
||||
|
@ -18,8 +18,13 @@ class SmartModule(Module):
|
||||
"""Base class for SMART modules."""
|
||||
|
||||
NAME: str
|
||||
REQUIRED_COMPONENT: str
|
||||
#: Module is initialized, if the given component is available
|
||||
REQUIRED_COMPONENT: str | None = None
|
||||
#: Module is initialized, if the given key available in the main sysinfo
|
||||
REQUIRED_KEY_ON_PARENT: str | None = None
|
||||
#: Query to execute during the main update cycle
|
||||
QUERY_GETTER_NAME: str
|
||||
|
||||
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
@ -27,8 +32,6 @@ class SmartModule(Module):
|
||||
super().__init__(device, module)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
assert cls.REQUIRED_COMPONENT is not None # noqa: S101
|
||||
|
||||
name = getattr(cls, "NAME", cls.__name__)
|
||||
_LOGGER.debug("Registering %s" % cls)
|
||||
cls.REGISTERED_MODULES[name] = cls
|
||||
@ -91,8 +94,13 @@ class SmartModule(Module):
|
||||
|
||||
@property
|
||||
def supported_version(self) -> int:
|
||||
"""Return version supported by the device."""
|
||||
"""Return version supported by the device.
|
||||
|
||||
If the module has no required component, this will return -1.
|
||||
"""
|
||||
if self.REQUIRED_COMPONENT is not None:
|
||||
return self._device._components[self.REQUIRED_COMPONENT]
|
||||
return -1
|
||||
|
||||
async def _check_supported(self) -> bool:
|
||||
"""Additional check to see if the module is supported by the device.
|
||||
|
@ -109,7 +109,7 @@ DIMMERS = {
|
||||
}
|
||||
|
||||
HUBS_SMART = {"H100", "KH100"}
|
||||
SENSORS_SMART = {"T310", "T315", "T300"}
|
||||
SENSORS_SMART = {"T310", "T315", "T300", "T110"}
|
||||
THERMOSTATS_SMART = {"KE100"}
|
||||
|
||||
WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT}
|
||||
@ -203,14 +203,14 @@ wallswitch_iot = parametrize(
|
||||
"wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"}
|
||||
)
|
||||
strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"})
|
||||
dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"})
|
||||
lightstrip = parametrize(
|
||||
dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"})
|
||||
lightstrip_iot = parametrize(
|
||||
"lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"}
|
||||
)
|
||||
|
||||
# bulb types
|
||||
dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"})
|
||||
non_dimmable = parametrize(
|
||||
dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"})
|
||||
non_dimmable_iot = parametrize(
|
||||
"non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"}
|
||||
)
|
||||
variable_temp = parametrize(
|
||||
@ -292,12 +292,12 @@ device_iot = parametrize(
|
||||
def check_categories():
|
||||
"""Check that every fixture file is categorized."""
|
||||
categorized_fixtures = set(
|
||||
dimmer.args[1]
|
||||
dimmer_iot.args[1]
|
||||
+ strip.args[1]
|
||||
+ plug.args[1]
|
||||
+ bulb.args[1]
|
||||
+ wallswitch.args[1]
|
||||
+ lightstrip.args[1]
|
||||
+ lightstrip_iot.args[1]
|
||||
+ bulb_smart.args[1]
|
||||
+ dimmers_smart.args[1]
|
||||
+ hubs_smart.args[1]
|
||||
|
@ -189,6 +189,11 @@ class FakeSmartTransport(BaseTransport):
|
||||
if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
|
||||
del info["get_dynamic_light_effect_rules"]["current_rule_id"]
|
||||
|
||||
def _set_led_info(self, info, params):
|
||||
"""Set or remove values as per the device behaviour."""
|
||||
info["get_led_info"]["led_status"] = params["led_rule"] != "never"
|
||||
info["get_led_info"]["led_rule"] = params["led_rule"]
|
||||
|
||||
def _send_request(self, request_dict: dict):
|
||||
method = request_dict["method"]
|
||||
params = request_dict["params"]
|
||||
@ -218,7 +223,9 @@ class FakeSmartTransport(BaseTransport):
|
||||
# SMART fixtures started to be generated
|
||||
missing_result := self.FIXTURE_MISSING_MAP.get(method)
|
||||
) and missing_result[0] in self.components:
|
||||
result = copy.deepcopy(missing_result[1])
|
||||
# Copy to info so it will work with update methods
|
||||
info[method] = copy.deepcopy(missing_result[1])
|
||||
result = copy.deepcopy(info[method])
|
||||
retval = {"result": result, "error_code": 0}
|
||||
else:
|
||||
# PARAMS error returned for KS240 when get_device_usage called
|
||||
@ -234,11 +241,14 @@ class FakeSmartTransport(BaseTransport):
|
||||
pytest.fixtures_missing_methods[self.fixture_name] = set()
|
||||
pytest.fixtures_missing_methods[self.fixture_name].add(method)
|
||||
return retval
|
||||
elif method == "set_qs_info":
|
||||
elif method in ["set_qs_info", "fw_download"]:
|
||||
return {"error_code": 0}
|
||||
elif method == "set_dynamic_light_effect_rule_enable":
|
||||
self._set_light_effect(info, params)
|
||||
return {"error_code": 0}
|
||||
elif method == "set_led_info":
|
||||
self._set_led_info(info, params)
|
||||
return {"error_code": 0}
|
||||
elif method[:4] == "set_":
|
||||
target_method = f"get_{method[4:]}"
|
||||
info[target_method].update(params)
|
||||
|
547
kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json
vendored
Normal file
547
kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
170
kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json
vendored
Normal file
170
kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json
vendored
Normal 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
|
||||
}
|
||||
}
|
526
kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json
vendored
Normal file
526
kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json
vendored
Normal 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
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from kasa.iot import IotDevice
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.tests.conftest import dimmable, parametrize
|
||||
from kasa.tests.conftest import dimmable_iot, parametrize
|
||||
|
||||
brightness = parametrize("brightness smart", component_filter="brightness")
|
||||
|
||||
@ -10,13 +10,13 @@ brightness = parametrize("brightness smart", component_filter="brightness")
|
||||
@brightness
|
||||
async def test_brightness_component(dev: SmartDevice):
|
||||
"""Test brightness feature."""
|
||||
brightness = dev.get_module("Brightness")
|
||||
brightness = dev.modules.get("Brightness")
|
||||
assert brightness
|
||||
assert isinstance(dev, SmartDevice)
|
||||
assert "brightness" in dev._components
|
||||
|
||||
# Test getting the value
|
||||
feature = brightness._module_features["brightness"]
|
||||
feature = dev.features["brightness"]
|
||||
assert isinstance(feature.value, int)
|
||||
assert feature.value > 1 and feature.value <= 100
|
||||
|
||||
@ -32,7 +32,7 @@ async def test_brightness_component(dev: SmartDevice):
|
||||
await feature.set_value(feature.maximum_value + 10)
|
||||
|
||||
|
||||
@dimmable
|
||||
@dimmable_iot
|
||||
async def test_brightness_dimmable(dev: IotDevice):
|
||||
"""Test brightness feature."""
|
||||
assert isinstance(dev, IotDevice)
|
||||
|
28
kasa/tests/smart/modules/test_contact.py
Normal file
28
kasa/tests/smart/modules/test_contact.py
Normal 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)
|
@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules import FanModule
|
||||
from kasa.tests.device_fixtures import parametrize
|
||||
|
||||
fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"})
|
||||
@ -11,10 +11,10 @@ fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"S
|
||||
@fan
|
||||
async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test fan speed feature."""
|
||||
fan = dev.get_module(FanModule)
|
||||
fan = dev.modules.get(Module.Fan)
|
||||
assert fan
|
||||
|
||||
level_feature = fan._module_features["fan_speed_level"]
|
||||
level_feature = dev.features["fan_speed_level"]
|
||||
assert (
|
||||
level_feature.minimum_value
|
||||
<= level_feature.value
|
||||
@ -36,9 +36,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
|
||||
@fan
|
||||
async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test sleep mode feature."""
|
||||
fan = dev.get_module(FanModule)
|
||||
fan = dev.modules.get(Module.Fan)
|
||||
assert fan
|
||||
sleep_feature = fan._module_features["fan_sleep_mode"]
|
||||
sleep_feature = dev.features["fan_sleep_mode"]
|
||||
assert isinstance(sleep_feature.value, bool)
|
||||
|
||||
call = mocker.spy(fan, "call")
|
||||
@ -52,29 +52,28 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
|
||||
|
||||
|
||||
@fan
|
||||
async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture):
|
||||
async def test_fan_module(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test fan speed on device interface."""
|
||||
assert isinstance(dev, SmartDevice)
|
||||
fan = dev.get_module(FanModule)
|
||||
fan = dev.modules.get(Module.Fan)
|
||||
assert fan
|
||||
device = fan._device
|
||||
assert device.is_fan
|
||||
|
||||
await device.set_fan_speed_level(1)
|
||||
await fan.set_fan_speed_level(1)
|
||||
await dev.update()
|
||||
assert device.fan_speed_level == 1
|
||||
assert fan.fan_speed_level == 1
|
||||
assert device.is_on
|
||||
|
||||
await device.set_fan_speed_level(4)
|
||||
await fan.set_fan_speed_level(4)
|
||||
await dev.update()
|
||||
assert device.fan_speed_level == 4
|
||||
assert fan.fan_speed_level == 4
|
||||
|
||||
await device.set_fan_speed_level(0)
|
||||
await fan.set_fan_speed_level(0)
|
||||
await dev.update()
|
||||
assert not device.is_on
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await device.set_fan_speed_level(-1)
|
||||
await fan.set_fan_speed_level(-1)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await device.set_fan_speed_level(5)
|
||||
await fan.set_fan_speed_level(5)
|
||||
|
113
kasa/tests/smart/modules/test_firmware.py
Normal file
113
kasa/tests/smart/modules/test_firmware.py
Normal 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)
|
@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type):
|
||||
prop = getattr(humidity, feature)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = humidity._module_features[feature]
|
||||
feat = dev.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import chain
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Device, Feature
|
||||
from kasa.smart.modules import LightEffectModule
|
||||
from kasa import Device, Feature, Module
|
||||
from kasa.smart.modules import LightEffect
|
||||
from kasa.tests.device_fixtures import parametrize
|
||||
|
||||
light_effect = parametrize(
|
||||
@ -18,10 +17,10 @@ light_effect = parametrize(
|
||||
@light_effect
|
||||
async def test_light_effect(dev: Device, mocker: MockerFixture):
|
||||
"""Test light effect."""
|
||||
light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule"))
|
||||
assert light_effect
|
||||
light_effect = dev.modules.get(Module.LightEffect)
|
||||
assert isinstance(light_effect, LightEffect)
|
||||
|
||||
feature = light_effect._module_features["light_effect"]
|
||||
feature = dev.features["light_effect"]
|
||||
assert feature.type == Feature.Type.Choice
|
||||
|
||||
call = mocker.spy(light_effect, "call")
|
||||
@ -29,7 +28,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture):
|
||||
assert feature.choices
|
||||
for effect in chain(reversed(feature.choices), feature.choices):
|
||||
await light_effect.set_effect(effect)
|
||||
enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF
|
||||
enable = effect != LightEffect.LIGHT_EFFECTS_OFF
|
||||
params: dict[str, bool | str] = {"enable": enable}
|
||||
if enable:
|
||||
params["id"] = light_effect._scenes_names_to_id[effect]
|
||||
|
@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type):
|
||||
prop = getattr(temp_module, feature)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = temp_module._module_features[feature]
|
||||
feat = dev.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
@ -42,6 +42,6 @@ async def test_temperature_warning(dev):
|
||||
assert hasattr(temp_module, "temperature_warning")
|
||||
assert isinstance(temp_module.temperature_warning, bool)
|
||||
|
||||
feat = temp_module._module_features["temperature_warning"]
|
||||
feat = dev.features["temperature_warning"]
|
||||
assert feat.value == temp_module.temperature_warning
|
||||
assert isinstance(feat.value, bool)
|
||||
|
@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type):
|
||||
prop = getattr(temp_module, feature)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = temp_module._module_features[feature]
|
||||
feat = dev.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type):
|
||||
prop = getattr(waterleak, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = waterleak._module_features[feature]
|
||||
feat = dev.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
@ -7,19 +7,18 @@ from voluptuous import (
|
||||
Schema,
|
||||
)
|
||||
|
||||
from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException
|
||||
from kasa import Device, DeviceType, KasaException, LightPreset, Module
|
||||
from kasa.iot import IotBulb, IotDimmer
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from .conftest import (
|
||||
bulb,
|
||||
bulb_iot,
|
||||
color_bulb,
|
||||
color_bulb_iot,
|
||||
dimmable,
|
||||
dimmable_iot,
|
||||
handle_turn_on,
|
||||
non_color_bulb,
|
||||
non_dimmable,
|
||||
non_dimmable_iot,
|
||||
non_variable_temp,
|
||||
turn_on,
|
||||
variable_temp,
|
||||
@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb):
|
||||
@color_bulb
|
||||
@turn_on
|
||||
async def test_hsv(dev: Device, turn_on):
|
||||
assert isinstance(dev, Bulb)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert dev.is_color
|
||||
assert light.is_color
|
||||
|
||||
hue, saturation, brightness = dev.hsv
|
||||
hue, saturation, brightness = light.hsv
|
||||
assert 0 <= hue <= 360
|
||||
assert 0 <= saturation <= 100
|
||||
assert 0 <= brightness <= 100
|
||||
|
||||
await dev.set_hsv(hue=1, saturation=1, value=1)
|
||||
await light.set_hsv(hue=1, saturation=1, value=1)
|
||||
|
||||
await dev.update()
|
||||
hue, saturation, brightness = dev.hsv
|
||||
hue, saturation, brightness = light.hsv
|
||||
assert hue == 1
|
||||
assert saturation == 1
|
||||
assert brightness == 1
|
||||
@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker):
|
||||
|
||||
@color_bulb
|
||||
@turn_on
|
||||
async def test_invalid_hsv(dev: Bulb, turn_on):
|
||||
async def test_invalid_hsv(dev: Device, turn_on):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert dev.is_color
|
||||
assert light.is_color
|
||||
|
||||
for invalid_hue in [-1, 361, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type]
|
||||
await light.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type]
|
||||
|
||||
for invalid_saturation in [-1, 101, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type]
|
||||
await light.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type]
|
||||
|
||||
for invalid_brightness in [-1, 101, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type]
|
||||
await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@color_bulb
|
||||
@pytest.mark.skip("requires color feature")
|
||||
async def test_color_state_information(dev: Device):
|
||||
assert isinstance(dev, Bulb)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert "HSV" in dev.state_information
|
||||
assert dev.state_information["HSV"] == dev.hsv
|
||||
assert dev.state_information["HSV"] == light.hsv
|
||||
|
||||
|
||||
@non_color_bulb
|
||||
async def test_hsv_on_non_color(dev: Bulb):
|
||||
assert not dev.is_color
|
||||
async def test_hsv_on_non_color(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert not light.is_color
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
await dev.set_hsv(0, 0, 0)
|
||||
await light.set_hsv(0, 0, 0)
|
||||
with pytest.raises(KasaException):
|
||||
print(dev.hsv)
|
||||
print(light.hsv)
|
||||
|
||||
|
||||
@variable_temp
|
||||
@pytest.mark.skip("requires colortemp module")
|
||||
async def test_variable_temp_state_information(dev: Device):
|
||||
assert isinstance(dev, Bulb)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert "Color temperature" in dev.state_information
|
||||
assert dev.state_information["Color temperature"] == dev.color_temp
|
||||
assert dev.state_information["Color temperature"] == light.color_temp
|
||||
|
||||
|
||||
@variable_temp
|
||||
@turn_on
|
||||
async def test_try_set_colortemp(dev: Device, turn_on):
|
||||
assert isinstance(dev, Bulb)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
await dev.set_color_temp(2700)
|
||||
await light.set_color_temp(2700)
|
||||
await dev.update()
|
||||
assert dev.color_temp == 2700
|
||||
assert light.color_temp == 2700
|
||||
|
||||
|
||||
@variable_temp_iot
|
||||
@ -166,36 +173,42 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
|
||||
|
||||
|
||||
@variable_temp_smart
|
||||
async def test_smart_temp_range(dev: SmartDevice):
|
||||
assert dev.valid_temperature_range
|
||||
async def test_smart_temp_range(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert light.valid_temperature_range
|
||||
|
||||
|
||||
@variable_temp
|
||||
async def test_out_of_range_temperature(dev: Bulb):
|
||||
async def test_out_of_range_temperature(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_color_temp(1000)
|
||||
await light.set_color_temp(1000)
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_color_temp(10000)
|
||||
await light.set_color_temp(10000)
|
||||
|
||||
|
||||
@non_variable_temp
|
||||
async def test_non_variable_temp(dev: Bulb):
|
||||
async def test_non_variable_temp(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
with pytest.raises(KasaException):
|
||||
await dev.set_color_temp(2700)
|
||||
await light.set_color_temp(2700)
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
print(dev.valid_temperature_range)
|
||||
print(light.valid_temperature_range)
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
print(dev.color_temp)
|
||||
print(light.color_temp)
|
||||
|
||||
|
||||
@dimmable
|
||||
@dimmable_iot
|
||||
@turn_on
|
||||
async def test_dimmable_brightness(dev: Device, turn_on):
|
||||
assert isinstance(dev, (Bulb, IotDimmer))
|
||||
async def test_dimmable_brightness(dev: IotBulb, turn_on):
|
||||
assert isinstance(dev, (IotBulb, IotDimmer))
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert dev.is_dimmable
|
||||
assert dev._is_dimmable
|
||||
|
||||
await dev.set_brightness(50)
|
||||
await dev.update()
|
||||
@ -229,9 +242,9 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
|
||||
set_light_state.assert_called_with({"brightness": 10}, transition=1000)
|
||||
|
||||
|
||||
@dimmable
|
||||
async def test_invalid_brightness(dev: Bulb):
|
||||
assert dev.is_dimmable
|
||||
@dimmable_iot
|
||||
async def test_invalid_brightness(dev: IotBulb):
|
||||
assert dev._is_dimmable
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_brightness(110)
|
||||
@ -240,9 +253,9 @@ async def test_invalid_brightness(dev: Bulb):
|
||||
await dev.set_brightness(-100)
|
||||
|
||||
|
||||
@non_dimmable
|
||||
async def test_non_dimmable(dev: Bulb):
|
||||
assert not dev.is_dimmable
|
||||
@non_dimmable_iot
|
||||
async def test_non_dimmable(dev: IotBulb):
|
||||
assert not dev._is_dimmable
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
assert dev.brightness == 0
|
||||
@ -291,7 +304,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
|
||||
"saturation": 0,
|
||||
"color_temp": 0,
|
||||
}
|
||||
preset = BulbPreset(**data)
|
||||
preset = LightPreset(**data)
|
||||
|
||||
assert preset.index == 0
|
||||
assert preset.brightness == 10
|
||||
@ -305,7 +318,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
await dev.save_preset(
|
||||
BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0)
|
||||
LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0)
|
||||
)
|
||||
|
||||
|
||||
@ -314,11 +327,11 @@ async def test_modify_preset(dev: IotBulb, mocker):
|
||||
("preset", "payload"),
|
||||
[
|
||||
(
|
||||
BulbPreset(index=0, hue=0, brightness=1, saturation=0),
|
||||
LightPreset(index=0, hue=0, brightness=1, saturation=0),
|
||||
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
|
||||
),
|
||||
(
|
||||
BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0),
|
||||
LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0),
|
||||
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
|
||||
),
|
||||
],
|
||||
@ -380,7 +393,7 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
|
||||
|
||||
|
||||
@bulb
|
||||
def test_device_type_bulb(dev):
|
||||
def test_device_type_bulb(dev: Device):
|
||||
if dev.is_light_strip:
|
||||
pytest.skip("bulb has also lightstrips to test the api")
|
||||
assert dev.device_type == DeviceType.Bulb
|
||||
|
@ -13,6 +13,7 @@ from kasa import (
|
||||
DeviceError,
|
||||
EmeterStatus,
|
||||
KasaException,
|
||||
Module,
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.cli import (
|
||||
@ -21,11 +22,15 @@ from kasa.cli import (
|
||||
brightness,
|
||||
cli,
|
||||
cmd_command,
|
||||
effect,
|
||||
emeter,
|
||||
hsv,
|
||||
led,
|
||||
raw_command,
|
||||
reboot,
|
||||
state,
|
||||
sysinfo,
|
||||
temperature,
|
||||
toggle,
|
||||
update_credentials,
|
||||
wifi,
|
||||
@ -34,7 +39,6 @@ from kasa.discover import Discover, DiscoveryResult
|
||||
from kasa.iot import IotDevice
|
||||
|
||||
from .conftest import (
|
||||
device_iot,
|
||||
device_smart,
|
||||
get_device_for_fixture_protocol,
|
||||
handle_turn_on,
|
||||
@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner):
|
||||
update.assert_called()
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_sysinfo(dev, runner):
|
||||
async def test_sysinfo(dev: Device, runner):
|
||||
res = await runner.invoke(sysinfo, obj=dev)
|
||||
assert "System info" in res.output
|
||||
assert dev.alias in res.output
|
||||
assert dev.model in res.output
|
||||
|
||||
|
||||
@turn_on
|
||||
@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner):
|
||||
assert dev.is_on != turn_on
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_alias(dev, runner):
|
||||
res = await runner.invoke(alias, obj=dev)
|
||||
assert f"Alias: {dev.alias}" in res.output
|
||||
@ -308,15 +310,14 @@ async def test_emeter(dev: Device, mocker, runner):
|
||||
daily.assert_called_with(year=1900, month=12)
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_brightness(dev, runner):
|
||||
async def test_brightness(dev: Device, runner):
|
||||
res = await runner.invoke(brightness, obj=dev)
|
||||
if not dev.is_dimmable:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
||||
assert "This device does not support brightness." in res.output
|
||||
return
|
||||
|
||||
res = await runner.invoke(brightness, obj=dev)
|
||||
assert f"Brightness: {dev.brightness}" in res.output
|
||||
assert f"Brightness: {light.brightness}" in res.output
|
||||
|
||||
res = await runner.invoke(brightness, ["12"], obj=dev)
|
||||
assert "Setting brightness" in res.output
|
||||
@ -326,7 +327,110 @@ async def test_brightness(dev, runner):
|
||||
assert "Brightness: 12" in res.output
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_color_temperature(dev: Device, runner):
|
||||
res = await runner.invoke(temperature, obj=dev)
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
||||
assert "Device does not support color temperature" in res.output
|
||||
return
|
||||
|
||||
res = await runner.invoke(temperature, obj=dev)
|
||||
assert f"Color temperature: {light.color_temp}" in res.output
|
||||
valid_range = light.valid_temperature_range
|
||||
assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output
|
||||
|
||||
val = int((valid_range.min + valid_range.max) / 2)
|
||||
res = await runner.invoke(temperature, [str(val)], obj=dev)
|
||||
assert "Setting color temperature to " in res.output
|
||||
await dev.update()
|
||||
|
||||
res = await runner.invoke(temperature, obj=dev)
|
||||
assert f"Color temperature: {val}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
invalid_max = valid_range.max + 100
|
||||
# Lights that support the maximum range will not get past the click cli range check
|
||||
# So can't be tested for the internal range check.
|
||||
if invalid_max < 9000:
|
||||
res = await runner.invoke(temperature, [str(invalid_max)], obj=dev)
|
||||
assert res.exit_code == 1
|
||||
assert isinstance(res.exception, ValueError)
|
||||
|
||||
res = await runner.invoke(temperature, [str(9100)], obj=dev)
|
||||
assert res.exit_code == 2
|
||||
|
||||
|
||||
async def test_color_hsv(dev: Device, runner: CliRunner):
|
||||
res = await runner.invoke(hsv, obj=dev)
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
||||
assert "Device does not support colors" in res.output
|
||||
return
|
||||
|
||||
res = await runner.invoke(hsv, obj=dev)
|
||||
assert f"Current HSV: {light.hsv}" in res.output
|
||||
|
||||
res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev)
|
||||
assert "Setting HSV: 180 50 50" in res.output
|
||||
assert res.exit_code == 0
|
||||
await dev.update()
|
||||
|
||||
res = await runner.invoke(hsv, ["180", "50"], obj=dev)
|
||||
assert "Setting a color requires 3 values." in res.output
|
||||
assert res.exit_code == 2
|
||||
|
||||
|
||||
async def test_light_effect(dev: Device, runner: CliRunner):
|
||||
res = await runner.invoke(effect, obj=dev)
|
||||
if not (light_effect := dev.modules.get(Module.LightEffect)):
|
||||
assert "Device does not support effects" in res.output
|
||||
return
|
||||
|
||||
# Start off with a known state of off
|
||||
await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF)
|
||||
await dev.update()
|
||||
assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF
|
||||
|
||||
res = await runner.invoke(effect, obj=dev)
|
||||
msg = (
|
||||
"Setting an effect requires a named built-in effect: "
|
||||
+ f"{light_effect.effect_list}"
|
||||
)
|
||||
assert msg in res.output
|
||||
assert res.exit_code == 2
|
||||
|
||||
res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev)
|
||||
assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output
|
||||
assert res.exit_code == 0
|
||||
await dev.update()
|
||||
assert light_effect.effect == light_effect.effect_list[1]
|
||||
|
||||
res = await runner.invoke(effect, ["foobar"], obj=dev)
|
||||
assert f"Effect must be one of: {light_effect.effect_list}" in res.output
|
||||
assert res.exit_code == 2
|
||||
|
||||
|
||||
async def test_led(dev: Device, runner: CliRunner):
|
||||
res = await runner.invoke(led, obj=dev)
|
||||
if not (led_module := dev.modules.get(Module.Led)):
|
||||
assert "Device does not support led" in res.output
|
||||
return
|
||||
|
||||
res = await runner.invoke(led, obj=dev)
|
||||
assert f"LED state: {led_module.led}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(led, ["on"], obj=dev)
|
||||
assert "Turning led to True" in res.output
|
||||
assert res.exit_code == 0
|
||||
await dev.update()
|
||||
assert led_module.led is True
|
||||
|
||||
res = await runner.invoke(led, ["off"], obj=dev)
|
||||
assert "Turning led to False" in res.output
|
||||
assert res.exit_code == 0
|
||||
await dev.update()
|
||||
assert led_module.led is False
|
||||
|
||||
|
||||
async def test_json_output(dev: Device, mocker, runner):
|
||||
"""Test that the json output produces correct output."""
|
||||
mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev})
|
||||
@ -375,7 +479,6 @@ async def test_credentials(discovery_mock, mocker, runner):
|
||||
assert "Username:foo Password:bar\n" in res.output
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_without_device_type(dev, mocker, runner):
|
||||
"""Test connecting without the device type."""
|
||||
discovery_mock = mocker.patch(
|
||||
@ -737,7 +840,7 @@ async def test_feature_set(mocker, runner):
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"P300(EU)_1.0_1.0.13.json", "SMART"
|
||||
)
|
||||
led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led")
|
||||
led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led")
|
||||
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
|
||||
|
||||
res = await runner.invoke(
|
||||
|
125
kasa/tests/test_common_modules.py
Normal file
125
kasa/tests/test_common_modules.py
Normal 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)
|
@ -9,7 +9,7 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
import kasa
|
||||
from kasa import Credentials, Device, DeviceConfig
|
||||
from kasa import Credentials, Device, DeviceConfig, DeviceType
|
||||
from kasa.iot import IotDevice
|
||||
from kasa.smart import SmartChildDevice, SmartDevice
|
||||
|
||||
@ -25,6 +25,7 @@ def _get_subclasses(of_class):
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, of_class)
|
||||
and module.__package__ != "kasa"
|
||||
and module.__package__ != "kasa.interfaces"
|
||||
):
|
||||
subclasses.add((module.__package__ + "." + name, obj))
|
||||
return subclasses
|
||||
@ -120,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class):
|
||||
with pytest.deprecated_call(match=msg):
|
||||
getattr(kasa, exceptions_class)
|
||||
getattr(kasa, use_class.__name__)
|
||||
|
||||
|
||||
deprecated_is_device_type = {
|
||||
"is_bulb": DeviceType.Bulb,
|
||||
"is_plug": DeviceType.Plug,
|
||||
"is_dimmer": DeviceType.Dimmer,
|
||||
"is_light_strip": DeviceType.LightStrip,
|
||||
"is_wallswitch": DeviceType.WallSwitch,
|
||||
"is_strip": DeviceType.Strip,
|
||||
"is_strip_socket": DeviceType.StripSocket,
|
||||
}
|
||||
deprecated_is_light_function_smart_module = {
|
||||
"is_color": "Color",
|
||||
"is_dimmable": "Brightness",
|
||||
"is_variable_color_temp": "ColorTemperature",
|
||||
}
|
||||
|
||||
|
||||
def test_deprecated_attributes(dev: SmartDevice):
|
||||
"""Test deprecated attributes on all devices."""
|
||||
tested_keys = set()
|
||||
|
||||
def _test_attr(attribute):
|
||||
tested_keys.add(attribute)
|
||||
msg = f"{attribute} is deprecated"
|
||||
if module := Device._deprecated_attributes[attribute][0]:
|
||||
msg += f", use: {module} in device.modules instead"
|
||||
with pytest.deprecated_call(match=msg):
|
||||
val = getattr(dev, attribute)
|
||||
return val
|
||||
|
||||
for attribute in deprecated_is_device_type:
|
||||
val = _test_attr(attribute)
|
||||
expected_val = dev.device_type == deprecated_is_device_type[attribute]
|
||||
assert val == expected_val
|
||||
|
||||
for attribute in deprecated_is_light_function_smart_module:
|
||||
val = _test_attr(attribute)
|
||||
if isinstance(dev, SmartDevice):
|
||||
expected_val = (
|
||||
deprecated_is_light_function_smart_module[attribute] in dev.modules
|
||||
)
|
||||
elif hasattr(dev, f"_{attribute}"):
|
||||
expected_val = getattr(dev, f"_{attribute}")
|
||||
else:
|
||||
expected_val = False
|
||||
assert val == expected_val
|
||||
|
||||
assert len(tested_keys) == len(Device._deprecated_attributes)
|
||||
untested_keys = [
|
||||
key for key in Device._deprecated_attributes if key not in tested_keys
|
||||
]
|
||||
assert len(untested_keys) == 0
|
||||
|
@ -3,10 +3,10 @@ import pytest
|
||||
from kasa import DeviceType
|
||||
from kasa.iot import IotDimmer
|
||||
|
||||
from .conftest import dimmer, handle_turn_on, turn_on
|
||||
from .conftest import dimmer_iot, handle_turn_on, turn_on
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_brightness(dev, turn_on):
|
||||
await handle_turn_on(dev, turn_on)
|
||||
@ -22,7 +22,7 @@ async def test_set_brightness(dev, turn_on):
|
||||
assert dev.is_on == turn_on
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_brightness_transition(dev, turn_on, mocker):
|
||||
await handle_turn_on(dev, turn_on)
|
||||
@ -44,7 +44,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker):
|
||||
assert dev.brightness == 1
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
async def test_set_brightness_invalid(dev):
|
||||
for invalid_brightness in [-1, 101, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev):
|
||||
await dev.set_brightness(1, transition=invalid_transition)
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
async def test_turn_on_transition(dev, mocker):
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
original_brightness = dev.brightness
|
||||
@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker):
|
||||
assert dev.brightness == original_brightness
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
async def test_turn_off_transition(dev, mocker):
|
||||
await handle_turn_on(dev, True)
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker):
|
||||
)
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_dimmer_transition(dev, turn_on, mocker):
|
||||
await handle_turn_on(dev, turn_on)
|
||||
@ -108,7 +108,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker):
|
||||
assert dev.brightness == 99
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
|
||||
await handle_turn_on(dev, turn_on)
|
||||
@ -127,7 +127,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
|
||||
)
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
async def test_set_dimmer_transition_invalid(dev):
|
||||
for invalid_brightness in [-1, 101, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev):
|
||||
await dev.set_dimmer_transition(1, invalid_transition)
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
def test_device_type_dimmer(dev):
|
||||
assert dev.device_type == DeviceType.Dimmer
|
||||
|
@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption
|
||||
|
||||
from .conftest import (
|
||||
bulb_iot,
|
||||
dimmer,
|
||||
lightstrip,
|
||||
dimmer_iot,
|
||||
lightstrip_iot,
|
||||
new_discovery,
|
||||
plug_iot,
|
||||
strip_iot,
|
||||
@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device):
|
||||
assert d.device_type == DeviceType.Strip
|
||||
|
||||
|
||||
@dimmer
|
||||
@dimmer_iot
|
||||
async def test_type_detection_dimmer(dev: Device):
|
||||
d = Discover._get_device_class(dev._last_update)("localhost")
|
||||
assert d.is_dimmer
|
||||
assert d.device_type == DeviceType.Dimmer
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
async def test_type_detection_lightstrip(dev: Device):
|
||||
d = Discover._get_device_class(dev._last_update)("localhost")
|
||||
assert d.is_light_strip
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
@ -180,10 +181,9 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture):
|
||||
|
||||
async def _test_features(dev):
|
||||
exceptions = []
|
||||
query = mocker.patch.object(dev.protocol, "query")
|
||||
for feat in dev.features.values():
|
||||
query.reset_mock()
|
||||
try:
|
||||
with patch.object(feat.device.protocol, "query") as query:
|
||||
await _test_feature(feat, query)
|
||||
# we allow our own exceptions to avoid mocking valid responses
|
||||
except KasaException:
|
||||
|
@ -16,7 +16,7 @@ from voluptuous import (
|
||||
Schema,
|
||||
)
|
||||
|
||||
from kasa import KasaException
|
||||
from kasa import KasaException, Module
|
||||
from kasa.iot import IotDevice
|
||||
|
||||
from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on
|
||||
@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice):
|
||||
|
||||
|
||||
async def test_get_modules():
|
||||
"""Test get_modules for child and parent modules."""
|
||||
"""Test getting modules for child and parent modules."""
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"HS100(US)_2.0_1.5.6.json", "IOT"
|
||||
)
|
||||
from kasa.iot.modules import Cloud
|
||||
from kasa.smart.modules import CloudModule
|
||||
|
||||
# Modules on device
|
||||
module = dummy_device.get_module("Cloud")
|
||||
module = dummy_device.modules.get("cloud")
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
module = dummy_device.get_module(Cloud)
|
||||
module = dummy_device.modules.get(Module.IotCloud)
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
# Invalid modules
|
||||
module = dummy_device.get_module("DummyModule")
|
||||
module = dummy_device.modules.get("DummyModule")
|
||||
assert module is None
|
||||
|
||||
module = dummy_device.get_module(CloudModule)
|
||||
module = dummy_device.modules.get(Module.Cloud)
|
||||
assert module is None
|
||||
|
@ -1,29 +1,28 @@
|
||||
import pytest
|
||||
|
||||
from kasa import DeviceType
|
||||
from kasa.exceptions import KasaException
|
||||
from kasa.iot import IotLightStrip
|
||||
|
||||
from .conftest import lightstrip
|
||||
from .conftest import lightstrip_iot
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
async def test_lightstrip_length(dev: IotLightStrip):
|
||||
assert dev.is_light_strip
|
||||
assert dev.device_type == DeviceType.LightStrip
|
||||
assert dev.length == dev.sys_info["length"]
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
async def test_lightstrip_effect(dev: IotLightStrip):
|
||||
assert isinstance(dev.effect, dict)
|
||||
for k in ["brightness", "custom", "enable", "id", "name"]:
|
||||
assert k in dev.effect
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
|
||||
with pytest.raises(KasaException):
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_effect("Not real")
|
||||
|
||||
await dev.set_effect("Candy Cane")
|
||||
@ -31,7 +30,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
|
||||
assert dev.effect["name"] == "Candy Cane"
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
@pytest.mark.parametrize("brightness", [100, 50])
|
||||
async def test_effects_lightstrip_set_effect_brightness(
|
||||
dev: IotLightStrip, brightness, mocker
|
||||
@ -49,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness(
|
||||
assert payload["brightness"] == brightness
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
@pytest.mark.parametrize("transition", [500, 1000])
|
||||
async def test_effects_lightstrip_set_effect_transition(
|
||||
dev: IotLightStrip, transition, mocker
|
||||
@ -67,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition(
|
||||
assert payload["transition"] == transition
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
async def test_effects_lightstrip_has_effects(dev: IotLightStrip):
|
||||
assert dev.has_effects is True
|
||||
assert dev.effect_list
|
||||
|
||||
|
||||
@lightstrip
|
||||
@lightstrip_iot
|
||||
def test_device_type_lightstrip(dev):
|
||||
assert dev.device_type == DeviceType.LightStrip
|
||||
|
@ -9,12 +9,11 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import KasaException
|
||||
from kasa import KasaException, Module
|
||||
from kasa.exceptions import SmartErrorCode
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from .conftest import (
|
||||
bulb_smart,
|
||||
device_smart,
|
||||
get_device_for_fixture_protocol,
|
||||
)
|
||||
@ -123,65 +122,42 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
|
||||
|
||||
|
||||
async def test_get_modules():
|
||||
"""Test get_modules for child and parent modules."""
|
||||
"""Test getting modules for child and parent modules."""
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"KS240(US)_1.0_1.0.5.json", "SMART"
|
||||
)
|
||||
from kasa.iot.modules import AmbientLight
|
||||
from kasa.smart.modules import CloudModule, FanModule
|
||||
from kasa.smart.modules import Cloud
|
||||
|
||||
# Modules on device
|
||||
module = dummy_device.get_module("CloudModule")
|
||||
module = dummy_device.modules.get("Cloud")
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, CloudModule)
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
module = dummy_device.get_module(CloudModule)
|
||||
module = dummy_device.modules.get(Module.Cloud)
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, CloudModule)
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
# Modules on child
|
||||
module = dummy_device.get_module("FanModule")
|
||||
module = dummy_device.modules.get("Fan")
|
||||
assert module
|
||||
assert module._device != dummy_device
|
||||
assert module._device._parent == dummy_device
|
||||
|
||||
module = dummy_device.get_module(FanModule)
|
||||
module = dummy_device.modules.get(Module.Fan)
|
||||
assert module
|
||||
assert module._device != dummy_device
|
||||
assert module._device._parent == dummy_device
|
||||
|
||||
# Invalid modules
|
||||
module = dummy_device.get_module("DummyModule")
|
||||
module = dummy_device.modules.get("DummyModule")
|
||||
assert module is None
|
||||
|
||||
module = dummy_device.get_module(AmbientLight)
|
||||
module = dummy_device.modules.get(Module.IotAmbientLight)
|
||||
assert module is None
|
||||
|
||||
|
||||
@bulb_smart
|
||||
async def test_smartdevice_brightness(dev: SmartDevice):
|
||||
"""Test brightness setter and getter."""
|
||||
assert isinstance(dev, SmartDevice)
|
||||
assert "brightness" in dev._components
|
||||
|
||||
# Test getting the value
|
||||
feature = dev.features["brightness"]
|
||||
assert feature.minimum_value == 1
|
||||
assert feature.maximum_value == 100
|
||||
|
||||
await dev.set_brightness(10)
|
||||
await dev.update()
|
||||
assert dev.brightness == 10
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_brightness(feature.minimum_value - 10)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_brightness(feature.maximum_value + 10)
|
||||
|
||||
|
||||
@device_smart
|
||||
async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test is_cloud_connected property."""
|
||||
|
Loading…
x
Reference in New Issue
Block a user