Create common interfaces for remaining device types (#895)

Introduce common module interfaces across smart and iot devices and provide better typing implementation for getting modules to support this.
This commit is contained in:
Steven B 2024-05-10 19:29:28 +01:00 committed by GitHub
parent 7d4dc4c710
commit 9473d97ad2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 673 additions and 220 deletions

View File

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

View File

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

View File

@ -16,7 +16,7 @@ from importlib.metadata import version
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from warnings import warn from warnings import warn
from kasa.bulb import Bulb from kasa.bulb import Bulb, BulbPreset
from kasa.credentials import Credentials from kasa.credentials import Credentials
from kasa.device import Device from kasa.device import Device
from kasa.device_type import DeviceType from kasa.device_type import DeviceType
@ -36,12 +36,11 @@ from kasa.exceptions import (
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.feature import Feature from kasa.feature import Feature
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import ( from kasa.iotprotocol import (
IotProtocol, IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401 _deprecated_TPLinkSmartHomeProtocol, # noqa: F401
) )
from kasa.plug import Plug from kasa.module import Module
from kasa.protocol import BaseProtocol from kasa.protocol import BaseProtocol
from kasa.smartprotocol import SmartProtocol from kasa.smartprotocol import SmartProtocol
@ -62,6 +61,7 @@ __all__ = [
"Device", "Device",
"Bulb", "Bulb",
"Plug", "Plug",
"Module",
"KasaException", "KasaException",
"AuthenticationError", "AuthenticationError",
"DeviceError", "DeviceError",

View File

@ -54,11 +54,6 @@ class Bulb(Device, ABC):
def is_color(self) -> bool: def is_color(self) -> bool:
"""Whether the bulb supports color changes.""" """Whether the bulb supports color changes."""
@property
@abstractmethod
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
@property @property
@abstractmethod @abstractmethod
def is_variable_color_temp(self) -> bool: def is_variable_color_temp(self) -> bool:

View File

@ -6,7 +6,7 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Mapping, Sequence, overload from typing import TYPE_CHECKING, Any, Mapping, Sequence
from .credentials import Credentials from .credentials import Credentials
from .device_type import DeviceType from .device_type import DeviceType
@ -15,10 +15,13 @@ from .emeterstatus import EmeterStatus
from .exceptions import KasaException from .exceptions import KasaException
from .feature import Feature from .feature import Feature
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .module import Module, ModuleT from .module import Module
from .protocol import BaseProtocol from .protocol import BaseProtocol
from .xortransport import XorTransport from .xortransport import XorTransport
if TYPE_CHECKING:
from .modulemapping import ModuleMapping
@dataclass @dataclass
class WifiNetwork: class WifiNetwork:
@ -113,21 +116,9 @@ class Device(ABC):
@property @property
@abstractmethod @abstractmethod
def modules(self) -> Mapping[str, Module]: def modules(self) -> ModuleMapping[Module]:
"""Return the device modules.""" """Return the device modules."""
@overload
@abstractmethod
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
@overload
@abstractmethod
def get_module(self, module_type: str) -> Module | None: ...
@abstractmethod
def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None:
"""Return the module from the device modules or None if not present."""
@property @property
@abstractmethod @abstractmethod
def is_on(self) -> bool: def is_on(self) -> bool:

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

@ -0,0 +1,38 @@
"""Module for base light effect module."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..feature import Feature
from ..module import Module
class Led(Module, ABC):
"""Base interface to represent a LED module."""
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device=device,
container=self,
name="LED",
id="led",
icon="mdi:led",
attribute_getter="led",
attribute_setter="set_led",
type=Feature.Type.Switch,
category=Feature.Category.Config,
)
)
@property
@abstractmethod
def led(self) -> bool:
"""Return current led status."""
@abstractmethod
async def set_led(self, enable: bool) -> None:
"""Set led."""

View File

@ -0,0 +1,80 @@
"""Module for base light effect module."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..feature import Feature
from ..module import Module
class LightEffect(Module, ABC):
"""Interface to represent a light effect module."""
LIGHT_EFFECTS_OFF = "Off"
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="light_effect",
name="Light effect",
container=self,
attribute_getter="effect",
attribute_setter="set_effect",
category=Feature.Category.Primary,
type=Feature.Type.Choice,
choices_getter="effect_list",
)
)
@property
@abstractmethod
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
@property
@abstractmethod
def effect(self) -> str:
"""Return effect state or name."""
@property
@abstractmethod
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
@abstractmethod
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device.
If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
async def set_custom_effect(
self,
effect_dict: dict,
) -> None:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""

View File

@ -19,14 +19,15 @@ import functools
import inspect import inspect
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Mapping, Sequence, cast, overload from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
from ..device import Device, WifiNetwork from ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException from ..exceptions import KasaException
from ..feature import Feature from ..feature import Feature
from ..module import ModuleT from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotmodule import IotModule from .iotmodule import IotModule
from .modules import Emeter, Time from .modules import Emeter, Time
@ -190,7 +191,7 @@ class IotDevice(Device):
self._supported_modules: dict[str, IotModule] | None = None self._supported_modules: dict[str, IotModule] | None = None
self._legacy_features: set[str] = set() self._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {} self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str, IotModule] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {}
@property @property
def children(self) -> Sequence[IotDevice]: def children(self) -> Sequence[IotDevice]:
@ -198,38 +199,20 @@ class IotDevice(Device):
return list(self._children.values()) return list(self._children.values())
@property @property
def modules(self) -> dict[str, IotModule]: def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules.""" """Return the device modules."""
if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._modules)
return self._modules return self._modules
@overload def add_module(self, name: str | ModuleName[Module], module: IotModule):
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
@overload
def get_module(self, module_type: str) -> IotModule | None: ...
def get_module(
self, module_type: type[ModuleT] | str
) -> ModuleT | IotModule | None:
"""Return the module from the device modules or None if not present."""
if isinstance(module_type, str):
module_name = module_type.lower()
elif issubclass(module_type, IotModule):
module_name = module_type.__name__.lower()
else:
return None
if module_name in self.modules:
return self.modules[module_name]
return None
def add_module(self, name: str, module: IotModule):
"""Register a module.""" """Register a module."""
if name in self.modules: if name in self.modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name) _LOGGER.debug("Module %s already registered, ignoring..." % name)
return return
_LOGGER.debug("Adding module %s", module) _LOGGER.debug("Adding module %s", module)
self.modules[name] = module self._modules[name] = module
def _create_request( def _create_request(
self, target: str, cmd: str, arg: dict | None = None, child_ids=None self, target: str, cmd: str, arg: dict | None = None, child_ids=None
@ -291,11 +274,11 @@ class IotDevice(Device):
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def supported_modules(self) -> list[str]: def supported_modules(self) -> list[str | ModuleName[Module]]:
"""Return a set of modules supported by the device.""" """Return a set of modules supported by the device."""
# TODO: this should rather be called `features`, but we don't want to break # TODO: this should rather be called `features`, but we don't want to break
# the API now. Maybe just deprecate it and point the users to use this? # the API now. Maybe just deprecate it and point the users to use this?
return list(self.modules.keys()) return list(self._modules.keys())
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -324,10 +307,11 @@ class IotDevice(Device):
self._last_update = response self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"]) self._set_sys_info(response["system"]["get_sysinfo"])
await self._modular_update(req)
if not self._features: if not self._features:
await self._initialize_features() await self._initialize_features()
await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"]) self._set_sys_info(self._last_update["system"]["get_sysinfo"])
async def _initialize_features(self): async def _initialize_features(self):
@ -352,6 +336,11 @@ class IotDevice(Device):
) )
) )
for module in self._modules.values():
module._initialize_features()
for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def _modular_update(self, req: dict) -> None: async def _modular_update(self, req: dict) -> None:
"""Execute an update query.""" """Execute an update query."""
if self.has_emeter: if self.has_emeter:
@ -364,17 +353,15 @@ class IotDevice(Device):
# making separate handling for this unnecessary # making separate handling for this unnecessary
if self._supported_modules is None: if self._supported_modules is None:
supported = {} supported = {}
for module in self.modules.values(): for module in self._modules.values():
if module.is_supported: if module.is_supported:
supported[module._module] = module supported[module._module] = module
for module_feat in module._module_features.values():
self._add_feature(module_feat)
self._supported_modules = supported self._supported_modules = supported
request_list = [] request_list = []
est_response_size = 1024 if "system" in req else 0 est_response_size = 1024 if "system" in req else 0
for module in self.modules.values(): for module in self._modules.values():
if not module.is_supported: if not module.is_supported:
_LOGGER.debug("Module %s not supported, skipping" % module) _LOGGER.debug("Module %s not supported, skipping" % module)
continue continue

View File

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

View File

@ -43,13 +43,19 @@ class IotModule(Module):
@property @property
def data(self): def data(self):
"""Return the module specific raw data from the last update.""" """Return the module specific raw data from the last update."""
if self._module not in self._device._last_update: dev = self._device
q = self.query()
if not q:
return dev.sys_info
if self._module not in dev._last_update:
raise KasaException( raise KasaException(
f"You need to call update() prior accessing module data" f"You need to call update() prior accessing module data"
f" for '{self._module}'" f" for '{self._module}'"
) )
return self._device._last_update[self._module] return dev._last_update[self._module]
@property @property
def is_supported(self) -> bool: def is_supported(self) -> bool:

View File

@ -6,10 +6,10 @@ import logging
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import IotDevice, requires_update from .iotdevice import IotDevice, requires_update
from .modules import Antitheft, Cloud, Schedule, Time, Usage from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -58,21 +58,7 @@ class IotPlug(IotDevice):
self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("antitheft", Antitheft(self, "anti_theft"))
self.add_module("time", Time(self, "time")) self.add_module("time", Time(self, "time"))
self.add_module("cloud", Cloud(self, "cnCloud")) self.add_module("cloud", Cloud(self, "cnCloud"))
self.add_module(Module.Led, LedModule(self, "system"))
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,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -93,14 +79,11 @@ class IotPlug(IotDevice):
@requires_update @requires_update
def led(self) -> bool: def led(self) -> bool:
"""Return the state of the led.""" """Return the state of the led."""
sys_info = self.sys_info return self.modules[Module.Led].led
return bool(1 - sys_info["led_off"])
async def set_led(self, state: bool): async def set_led(self, state: bool):
"""Set the state of the led (night mode).""" """Set the state of the led (night mode)."""
return await self._query_helper( return await self.modules[Module.Led].set_led(state)
"system", "set_led_off", {"off": int(not state)}
)
class IotWallSwitch(IotPlug): class IotWallSwitch(IotPlug):

View File

@ -253,7 +253,6 @@ class IotStripPlug(IotPlug):
self._last_update = parent._last_update self._last_update = parent._last_update
self._set_sys_info(parent.sys_info) self._set_sys_info(parent.sys_info)
self._device_type = DeviceType.StripSocket self._device_type = DeviceType.StripSocket
self._modules = {}
self.protocol = parent.protocol # Must use the same connection as the parent self.protocol = parent.protocol # Must use the same connection as the parent
self.add_module("time", Time(self, "time")) self.add_module("time", Time(self, "time"))

View File

@ -5,6 +5,7 @@ from .antitheft import Antitheft
from .cloud import Cloud from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .emeter import Emeter from .emeter import Emeter
from .ledmodule import LedModule
from .motion import Motion from .motion import Motion
from .rulemodule import Rule, RuleModule from .rulemodule import Rule, RuleModule
from .schedule import Schedule from .schedule import Schedule
@ -17,6 +18,7 @@ __all__ = [
"Cloud", "Cloud",
"Countdown", "Countdown",
"Emeter", "Emeter",
"LedModule",
"Motion", "Motion",
"Rule", "Rule",
"RuleModule", "RuleModule",

View File

@ -0,0 +1,32 @@
"""Module for led controls."""
from __future__ import annotations
from ...interfaces.led import Led
from ..iotmodule import IotModule
class LedModule(IotModule, Led):
"""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)})

View File

@ -0,0 +1,97 @@
"""Module for light effects."""
from __future__ import annotations
from ...interfaces.lighteffect import LightEffect
from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
from ..iotmodule import IotModule
class LightEffectModule(IotModule, LightEffect):
"""Implementation of dynamic light effects."""
@property
def effect(self) -> str:
"""Return effect state.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
if (
(state := self.data.get("lighting_effect_state"))
and state.get("enable")
and (name := state.get("name"))
and name in EFFECT_NAMES_V1
):
return name
return self.LIGHT_EFFECTS_OFF
@property
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
effect_list = [self.LIGHT_EFFECTS_OFF]
effect_list.extend(EFFECT_NAMES_V1)
return effect_list
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device.
If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
if effect == self.LIGHT_EFFECTS_OFF:
effect_dict = dict(self.data["lighting_effect_state"])
effect_dict["enable"] = 0
elif effect not in EFFECT_MAPPING_V1:
raise ValueError(f"The effect {effect} is not a built in effect.")
else:
effect_dict = EFFECT_MAPPING_V1[effect]
if brightness is not None:
effect_dict["brightness"] = brightness
if transition is not None:
effect_dict["transition"] = transition
await self.set_custom_effect(effect_dict)
async def set_custom_effect(
self,
effect_dict: dict,
) -> None:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""
return await self.call(
"set_lighting_effect",
effect_dict,
)
@property
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
return True
def query(self):
"""Return the base query."""
return {}

View File

@ -6,14 +6,20 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Final,
TypeVar, TypeVar,
) )
from .exceptions import KasaException from .exceptions import KasaException
from .feature import Feature from .feature import Feature
from .modulemapping import ModuleName
if TYPE_CHECKING: if TYPE_CHECKING:
from .device import Device from .device import Device as DeviceType # avoid name clash with Device module
from .interfaces.led import Led
from .interfaces.lighteffect import LightEffect
from .iot import modules as iot
from .smart import modules as smart
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,7 +33,59 @@ class Module(ABC):
executed during the regular update cycle. executed during the regular update cycle.
""" """
def __init__(self, device: Device, module: str): # Common Modules
LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule")
Led: Final[ModuleName[Led]] = ModuleName("LedModule")
# 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.AlarmModule]] = ModuleName("AlarmModule")
AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule")
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")
ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName(
"ChildDeviceModule"
)
Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule")
Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule")
ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName(
"ColorTemperatureModule"
)
ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor")
Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule")
Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule")
Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule")
Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware")
FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName(
"FrostProtectionModule"
)
Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor")
LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName(
"LightTransitionModule"
)
Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule")
Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
"TemperatureSensor"
)
TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName(
"TemperatureControl"
)
Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule")
WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName(
"WaterleakSensor"
)
def __init__(self, device: DeviceType, module: str):
self._device = device self._device = device
self._module = module self._module = module
self._module_features: dict[str, Feature] = {} self._module_features: dict[str, Feature] = {}

25
kasa/modulemapping.py Normal file
View File

@ -0,0 +1,25 @@
"""Module for Implementation for ModuleMapping and ModuleName types.
Custom dict for getting typed modules from the module dict.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Generic, TypeVar
if TYPE_CHECKING:
from .module import Module
_ModuleT = TypeVar("_ModuleT", bound="Module")
class ModuleName(str, Generic[_ModuleT]):
"""Generic Module name type.
At runtime this is a generic subclass of str.
"""
__slots__ = ()
ModuleMapping = dict

96
kasa/modulemapping.pyi Normal file
View File

@ -0,0 +1,96 @@
"""Typing stub file for ModuleMapping."""
from abc import ABCMeta
from collections.abc import Mapping
from typing import Generic, TypeVar, overload
from .module import Module
__all__ = [
"ModuleMapping",
"ModuleName",
]
_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True)
_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True)
class ModuleName(Generic[_ModuleT]):
"""Class for typed Module names. At runtime delegated to str."""
def __init__(self, value: str, /) -> None: ...
def __len__(self) -> int: ...
def __hash__(self) -> int: ...
def __eq__(self, other: object) -> bool: ...
def __getitem__(self, index: int) -> str: ...
class ModuleMapping(
Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta
):
"""Custom dict type to provide better value type hints for Module key types."""
@overload
def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ...
@overload
def __getitem__(self, key: str, /) -> _ModuleBaseT: ...
@overload
def __getitem__(
self, key: ModuleName[_ModuleT] | str, /
) -> _ModuleT | _ModuleBaseT: ...
@overload # type: ignore[override]
def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ...
@overload
def get(self, key: str, /) -> _ModuleBaseT | None: ...
@overload
def get(
self, key: ModuleName[_ModuleT] | str, /
) -> _ModuleT | _ModuleBaseT | None: ...
def _test_module_mapping_typing() -> None:
"""Test ModuleMapping overloads work as intended.
This is tested during the mypy run and needs to be in this file.
"""
from typing import Any, NewType, cast
from typing_extensions import assert_type
from .iot.iotmodule import IotModule
from .module import Module
from .smart.smartmodule import SmartModule
NewCommonModule = NewType("NewCommonModule", Module)
NewIotModule = NewType("NewIotModule", IotModule)
NewSmartModule = NewType("NewSmartModule", SmartModule)
NotModule = NewType("NotModule", list)
NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule")
NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule")
NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule")
# TODO Enable --warn-unused-ignores
NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841
NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841
device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {})
assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule)
assert_type(device_modules[NEW_IOT_MODULE], NewIotModule)
assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule)
assert_type(device_modules["foobar"], Module)
assert_type(device_modules[3], Any) # type: ignore[call-overload]
assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None)
assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None)
assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None)
assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload]
iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {})
smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {})
assert_type(smart_modules["foobar"], SmartModule)
assert_type(iot_modules["foobar"], IotModule)
# Test for covariance
device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841
device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841
NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841
NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841

View File

@ -1,12 +0,0 @@
"""Module for a TAPO Plug."""
import logging
from abc import ABC
from .device import Device
_LOGGER = logging.getLogger(__name__)
class Plug(Device, ABC):
"""Base class to represent a Plug."""

View File

@ -2,37 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from ...interfaces.led import Led
from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class LedModule(SmartModule, Led):
class LedModule(SmartModule):
"""Implementation of led controls.""" """Implementation of led controls."""
REQUIRED_COMPONENT = "led" REQUIRED_COMPONENT = "led"
QUERY_GETTER_NAME = "get_led_info" QUERY_GETTER_NAME = "get_led_info"
def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
self._add_feature(
Feature(
device=device,
container=self,
id="led",
name="LED",
icon="mdi:led-{state}",
attribute_getter="led",
attribute_setter="set_led",
type=Feature.Type.Switch,
category=Feature.Category.Config,
)
)
def query(self) -> dict: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {self.QUERY_GETTER_NAME: {"led_rule": None}} return {self.QUERY_GETTER_NAME: {"led_rule": None}}
@ -56,7 +35,7 @@ class LedModule(SmartModule):
This should probably be a select with always/never/nightmode. This should probably be a select with always/never/nightmode.
""" """
rule = "always" if enable else "never" rule = "always" if enable else "never"
return await self.call("set_led_info", self.data | {"led_rule": rule}) return await self.call("set_led_info", dict(self.data, **{"led_rule": rule}))
@property @property
def night_mode_settings(self): def night_mode_settings(self):

View File

@ -6,14 +6,14 @@ import base64
import copy import copy
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ...feature import Feature from ...interfaces.lighteffect import LightEffect
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
class LightEffectModule(SmartModule): class LightEffectModule(SmartModule, LightEffect):
"""Implementation of dynamic light effects.""" """Implementation of dynamic light effects."""
REQUIRED_COMPONENT = "light_effect" REQUIRED_COMPONENT = "light_effect"
@ -22,29 +22,11 @@ class LightEffectModule(SmartModule):
"L1": "Party", "L1": "Party",
"L2": "Relax", "L2": "Relax",
} }
LIGHT_EFFECTS_OFF = "Off"
def __init__(self, device: SmartDevice, module: str): def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module) super().__init__(device, module)
self._scenes_names_to_id: dict[str, str] = {} self._scenes_names_to_id: dict[str, str] = {}
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="light_effect",
name="Light effect",
container=self,
attribute_getter="effect",
attribute_setter="set_effect",
category=Feature.Category.Config,
type=Feature.Type.Choice,
choices_getter="effect_list",
)
)
def _initialize_effects(self) -> dict[str, dict[str, Any]]: def _initialize_effects(self) -> dict[str, dict[str, Any]]:
"""Return built-in effects.""" """Return built-in effects."""
# Copy the effects so scene name updates do not update the underlying dict. # Copy the effects so scene name updates do not update the underlying dict.
@ -64,7 +46,7 @@ class LightEffectModule(SmartModule):
return effects return effects
@property @property
def effect_list(self) -> list[str] | None: def effect_list(self) -> list[str]:
"""Return built-in effects list. """Return built-in effects list.
Example: Example:
@ -90,6 +72,9 @@ class LightEffectModule(SmartModule):
async def set_effect( async def set_effect(
self, self,
effect: str, effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None: ) -> None:
"""Set an effect for the device. """Set an effect for the device.
@ -108,6 +93,24 @@ class LightEffectModule(SmartModule):
params["id"] = effect_id params["id"] = effect_id
return await self.call("set_dynamic_light_effect_rule_enable", params) return await self.call("set_dynamic_light_effect_rule_enable", params)
async def set_custom_effect(
self,
effect_dict: dict,
) -> None:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""
raise NotImplementedError(
"Device does not support setting custom effects. "
"Use has_custom_effects to check for support."
)
@property
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
return False
def query(self) -> dict: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {self.QUERY_GETTER_NAME: {"start_index": 0}} return {self.QUERY_GETTER_NAME: {"start_index": 0}}

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import base64 import base64
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Mapping, Sequence, cast, overload from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
from ..aestransport import AesTransport from ..aestransport import AesTransport
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
@ -16,7 +16,8 @@ from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..fan import Fan from ..fan import Fan
from ..feature import Feature from ..feature import Feature
from ..module import ModuleT from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
from .modules import ( from .modules import (
Brightness, Brightness,
@ -61,7 +62,7 @@ class SmartDevice(Bulb, Fan, Device):
self._components_raw: dict[str, Any] | None = None self._components_raw: dict[str, Any] | None = None
self._components: dict[str, int] = {} self._components: dict[str, int] = {}
self._state_information: dict[str, Any] = {} self._state_information: dict[str, Any] = {}
self._modules: dict[str, SmartModule] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {}
self._exposes_child_modules = False self._exposes_child_modules = False
self._parent: SmartDevice | None = None self._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {} self._children: Mapping[str, SmartDevice] = {}
@ -102,8 +103,20 @@ class SmartDevice(Bulb, Fan, Device):
return list(self._children.values()) return list(self._children.values())
@property @property
def modules(self) -> dict[str, SmartModule]: def modules(self) -> ModuleMapping[SmartModule]:
"""Return the device modules.""" """Return the device modules."""
if self._exposes_child_modules:
modules = {k: v for k, v in self._modules.items()}
for child in self._children.values():
for k, v in child._modules.items():
if k not in modules:
modules[k] = v
if TYPE_CHECKING:
return cast(ModuleMapping[SmartModule], modules)
return modules
if TYPE_CHECKING: # Needed for python 3.8
return cast(ModuleMapping[SmartModule], self._modules)
return self._modules return self._modules
def _try_get_response(self, responses: dict, request: str, default=None) -> dict: def _try_get_response(self, responses: dict, request: str, default=None) -> dict:
@ -315,30 +328,6 @@ class SmartDevice(Bulb, Fan, Device):
for feat in module._module_features.values(): for feat in module._module_features.values():
self._add_feature(feat) self._add_feature(feat)
@overload
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
@overload
def get_module(self, module_type: str) -> SmartModule | None: ...
def get_module(
self, module_type: type[ModuleT] | str
) -> ModuleT | SmartModule | None:
"""Return the module from the device modules or None if not present."""
if isinstance(module_type, str):
module_name = module_type
elif issubclass(module_type, SmartModule):
module_name = module_type.__name__
else:
return None
if module_name in self.modules:
return self.modules[module_name]
elif self._exposes_child_modules:
for child in self._children.values():
if module_name in child.modules:
return child.modules[module_name]
return None
@property @property
def is_cloud_connected(self): def is_cloud_connected(self):
"""Returns if the device is connected to the cloud.""" """Returns if the device is connected to the cloud."""

View File

@ -189,6 +189,11 @@ class FakeSmartTransport(BaseTransport):
if "current_rule_id" in info["get_dynamic_light_effect_rules"]: if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
del info["get_dynamic_light_effect_rules"]["current_rule_id"] del info["get_dynamic_light_effect_rules"]["current_rule_id"]
def _set_led_info(self, info, params):
"""Set or remove values as per the device behaviour."""
info["get_led_info"]["led_status"] = params["led_rule"] != "never"
info["get_led_info"]["led_rule"] = params["led_rule"]
def _send_request(self, request_dict: dict): def _send_request(self, request_dict: dict):
method = request_dict["method"] method = request_dict["method"]
params = request_dict["params"] params = request_dict["params"]
@ -218,7 +223,9 @@ class FakeSmartTransport(BaseTransport):
# SMART fixtures started to be generated # SMART fixtures started to be generated
missing_result := self.FIXTURE_MISSING_MAP.get(method) missing_result := self.FIXTURE_MISSING_MAP.get(method)
) and missing_result[0] in self.components: ) and missing_result[0] in self.components:
result = copy.deepcopy(missing_result[1]) # Copy to info so it will work with update methods
info[method] = copy.deepcopy(missing_result[1])
result = copy.deepcopy(info[method])
retval = {"result": result, "error_code": 0} retval = {"result": result, "error_code": 0}
else: else:
# PARAMS error returned for KS240 when get_device_usage called # PARAMS error returned for KS240 when get_device_usage called
@ -239,6 +246,9 @@ class FakeSmartTransport(BaseTransport):
elif method == "set_dynamic_light_effect_rule_enable": elif method == "set_dynamic_light_effect_rule_enable":
self._set_light_effect(info, params) self._set_light_effect(info, params)
return {"error_code": 0} return {"error_code": 0}
elif method == "set_led_info":
self._set_led_info(info, params)
return {"error_code": 0}
elif method[:4] == "set_": elif method[:4] == "set_":
target_method = f"get_{method[4:]}" target_method = f"get_{method[4:]}"
info[target_method].update(params) info[target_method].update(params)

View File

@ -10,7 +10,7 @@ brightness = parametrize("brightness smart", component_filter="brightness")
@brightness @brightness
async def test_brightness_component(dev: SmartDevice): async def test_brightness_component(dev: SmartDevice):
"""Test brightness feature.""" """Test brightness feature."""
brightness = dev.get_module("Brightness") brightness = dev.modules.get("Brightness")
assert brightness assert brightness
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
assert "brightness" in dev._components assert "brightness" in dev._components

View File

@ -1,7 +1,6 @@
import pytest import pytest
from kasa import SmartDevice from kasa import Module, SmartDevice
from kasa.smart.modules import ContactSensor
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
contact = parametrize( contact = parametrize(
@ -18,7 +17,7 @@ contact = parametrize(
) )
async def test_contact_features(dev: SmartDevice, feature, type): async def test_contact_features(dev: SmartDevice, feature, type):
"""Test that features are registered and work as expected.""" """Test that features are registered and work as expected."""
contact = dev.get_module(ContactSensor) contact = dev.modules.get(Module.ContactSensor)
assert contact is not None assert contact is not None
prop = getattr(contact, feature) prop = getattr(contact, feature)

View File

@ -1,8 +1,8 @@
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Module
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smart.modules import FanModule
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"})
@ -11,7 +11,7 @@ fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"S
@fan @fan
async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
"""Test fan speed feature.""" """Test fan speed feature."""
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
level_feature = fan._module_features["fan_speed_level"] level_feature = fan._module_features["fan_speed_level"]
@ -36,7 +36,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
@fan @fan
async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
"""Test sleep mode feature.""" """Test sleep mode feature."""
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
sleep_feature = fan._module_features["fan_sleep_mode"] sleep_feature = fan._module_features["fan_sleep_mode"]
assert isinstance(sleep_feature.value, bool) assert isinstance(sleep_feature.value, bool)
@ -55,7 +55,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture):
"""Test fan speed on device interface.""" """Test fan speed on device interface."""
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
fan = dev.get_module(FanModule) fan = dev.modules.get(Module.Fan)
assert fan assert fan
device = fan._device device = fan._device
assert device.is_fan assert device.is_fan

View File

@ -6,8 +6,8 @@ import logging
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Module
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smart.modules import Firmware
from kasa.smart.modules.firmware import DownloadState from kasa.smart.modules.firmware import DownloadState
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
@ -31,7 +31,7 @@ async def test_firmware_features(
dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture
): ):
"""Test light effect.""" """Test light effect."""
fw = dev.get_module(Firmware) fw = dev.modules.get(Module.Firmware)
assert fw assert fw
if not dev.is_cloud_connected: if not dev.is_cloud_connected:
@ -51,7 +51,7 @@ async def test_firmware_features(
@firmware @firmware
async def test_update_available_without_cloud(dev: SmartDevice): async def test_update_available_without_cloud(dev: SmartDevice):
"""Test that update_available returns None when disconnected.""" """Test that update_available returns None when disconnected."""
fw = dev.get_module(Firmware) fw = dev.modules.get(Module.Firmware)
assert fw assert fw
if dev.is_cloud_connected: if dev.is_cloud_connected:
@ -67,7 +67,7 @@ async def test_firmware_update(
"""Test updating firmware.""" """Test updating firmware."""
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
fw = dev.get_module(Firmware) fw = dev.modules.get(Module.Firmware)
assert fw assert fw
upgrade_time = 5 upgrade_time = 5

View File

@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
from itertools import chain from itertools import chain
from typing import cast
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, Feature from kasa import Device, Feature, Module
from kasa.smart.modules import LightEffectModule from kasa.smart.modules import LightEffectModule
from kasa.tests.device_fixtures import parametrize from kasa.tests.device_fixtures import parametrize
@ -18,8 +17,8 @@ light_effect = parametrize(
@light_effect @light_effect
async def test_light_effect(dev: Device, mocker: MockerFixture): async def test_light_effect(dev: Device, mocker: MockerFixture):
"""Test light effect.""" """Test light effect."""
light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) light_effect = dev.modules.get(Module.LightEffect)
assert light_effect assert isinstance(light_effect, LightEffectModule)
feature = light_effect._module_features["light_effect"] feature = light_effect._module_features["light_effect"]
assert feature.type == Feature.Type.Choice assert feature.type == Feature.Type.Choice

View File

@ -0,0 +1,95 @@
import pytest
from pytest_mock import MockerFixture
from kasa import Device, Module
from kasa.tests.device_fixtures import (
lightstrip,
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])
@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 = led_module._module_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 = light_effect_module._module_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

View File

@ -16,7 +16,7 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import KasaException from kasa import KasaException, Module
from kasa.iot import IotDevice from kasa.iot import IotDevice
from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on
@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice):
async def test_get_modules(): async def test_get_modules():
"""Test get_modules for child and parent modules.""" """Test getting modules for child and parent modules."""
dummy_device = await get_device_for_fixture_protocol( dummy_device = await get_device_for_fixture_protocol(
"HS100(US)_2.0_1.5.6.json", "IOT" "HS100(US)_2.0_1.5.6.json", "IOT"
) )
from kasa.iot.modules import Cloud from kasa.iot.modules import Cloud
from kasa.smart.modules import CloudModule
# Modules on device # Modules on device
module = dummy_device.get_module("Cloud") module = dummy_device.modules.get("cloud")
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, Cloud) assert isinstance(module, Cloud)
module = dummy_device.get_module(Cloud) module = dummy_device.modules.get(Module.IotCloud)
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, Cloud) assert isinstance(module, Cloud)
# Invalid modules # Invalid modules
module = dummy_device.get_module("DummyModule") module = dummy_device.modules.get("DummyModule")
assert module is None assert module is None
module = dummy_device.get_module(CloudModule) module = dummy_device.modules.get(Module.Cloud)
assert module is None assert module is None

View File

@ -1,7 +1,6 @@
import pytest import pytest
from kasa import DeviceType from kasa import DeviceType
from kasa.exceptions import KasaException
from kasa.iot import IotLightStrip from kasa.iot import IotLightStrip
from .conftest import lightstrip from .conftest import lightstrip
@ -23,7 +22,7 @@ async def test_lightstrip_effect(dev: IotLightStrip):
@lightstrip @lightstrip
async def test_effects_lightstrip_set_effect(dev: IotLightStrip): async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
with pytest.raises(KasaException): with pytest.raises(ValueError):
await dev.set_effect("Not real") await dev.set_effect("Not real")
await dev.set_effect("Candy Cane") await dev.set_effect("Candy Cane")

View File

@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import KasaException from kasa import KasaException, Module
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
@ -123,40 +123,39 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
async def test_get_modules(): async def test_get_modules():
"""Test get_modules for child and parent modules.""" """Test getting modules for child and parent modules."""
dummy_device = await get_device_for_fixture_protocol( dummy_device = await get_device_for_fixture_protocol(
"KS240(US)_1.0_1.0.5.json", "SMART" "KS240(US)_1.0_1.0.5.json", "SMART"
) )
from kasa.iot.modules import AmbientLight from kasa.smart.modules import CloudModule
from kasa.smart.modules import CloudModule, FanModule
# Modules on device # Modules on device
module = dummy_device.get_module("CloudModule") module = dummy_device.modules.get("CloudModule")
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, CloudModule) assert isinstance(module, CloudModule)
module = dummy_device.get_module(CloudModule) module = dummy_device.modules.get(Module.Cloud)
assert module assert module
assert module._device == dummy_device assert module._device == dummy_device
assert isinstance(module, CloudModule) assert isinstance(module, CloudModule)
# Modules on child # Modules on child
module = dummy_device.get_module("FanModule") module = dummy_device.modules.get("FanModule")
assert module assert module
assert module._device != dummy_device assert module._device != dummy_device
assert module._device._parent == dummy_device assert module._device._parent == dummy_device
module = dummy_device.get_module(FanModule) module = dummy_device.modules.get(Module.Fan)
assert module assert module
assert module._device != dummy_device assert module._device != dummy_device
assert module._device._parent == dummy_device assert module._device._parent == dummy_device
# Invalid modules # Invalid modules
module = dummy_device.get_module("DummyModule") module = dummy_device.modules.get("DummyModule")
assert module is None assert module is None
module = dummy_device.get_module(AmbientLight) module = dummy_device.modules.get(Module.IotAmbientLight)
assert module is None assert module is None