From cada3c7509c25729cb605aaa3d7ded1bdfc5eeb3 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 25 Apr 2024 15:55:44 +0100 Subject: [PATCH 1/9] Add Fan interface for devices --- kasa/fan.py | 32 ++++++++++++++++++++++++ kasa/smart/smartdevice.py | 51 ++++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 kasa/fan.py diff --git a/kasa/fan.py b/kasa/fan.py new file mode 100644 index 000000000..aa320ce54 --- /dev/null +++ b/kasa/fan.py @@ -0,0 +1,32 @@ +"""Module for Fan Interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Fan(ABC): + """Interface for a Fan.""" + + @property + def is_fan(self) -> bool: + """Return True if the device is a fan.""" + return False + + @property + def fan_speed_level(self) -> int: + """Return fan speed level.""" + raise + + @abstractmethod + async def set_fan_speed_level(self, level: int): + """Set fan speed level.""" + + @property + @abstractmethod + def sleep_mode(self) -> bool: + """Return sleep mode status.""" + + @abstractmethod + async def set_sleep_mode(self, on: bool): + """Set sleep mode.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index b325614be..e19b9c328 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -13,9 +13,17 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode +from ..fan import Fan from ..feature import Feature from ..smartprotocol import SmartProtocol -from .modules import * # noqa: F403 +from .modules import ( + CloudModule, + DeviceModule, + EnergyModule, + FanModule, + Firmware, + TimeModule, +) _LOGGER = logging.getLogger(__name__) @@ -26,10 +34,10 @@ # 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] # noqa: F405 +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] -class SmartDevice(Device): +class SmartDevice(Device, Fan): """Base class to represent a SMART protocol based device.""" def __init__( @@ -428,19 +436,19 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) 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"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) return energy.emeter_this_month @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) return energy.emeter_today @property @@ -602,3 +610,34 @@ def _get_device_type_from_components( return DeviceType.WallSwitch _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug + + @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) + + @property + def sleep_mode(self) -> bool: + """Return sleep mode status.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + return cast(FanModule, self.modules["FanModule"]).sleep_mode + + async def set_sleep_mode(self, on: bool): + """Set sleep mode.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + await cast(FanModule, self.modules["FanModule"]).set_sleep_mode(on) From a458db79ca422968d340cfe4943a02b6c3159c9a Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 26 Apr 2024 08:13:05 +0100 Subject: [PATCH 2/9] Remove sleep mode from Fan interface --- kasa/fan.py | 9 --------- kasa/smart/smartdevice.py | 13 ------------- kasa/tests/test_childdevice.py | 6 +++++- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/kasa/fan.py b/kasa/fan.py index aa320ce54..1b76f0375 100644 --- a/kasa/fan.py +++ b/kasa/fan.py @@ -21,12 +21,3 @@ def fan_speed_level(self) -> int: @abstractmethod async def set_fan_speed_level(self, level: int): """Set fan speed level.""" - - @property - @abstractmethod - def sleep_mode(self) -> bool: - """Return sleep mode status.""" - - @abstractmethod - async def set_sleep_mode(self, on: bool): - """Set sleep mode.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e19b9c328..dbea53dd5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -628,16 +628,3 @@ async def set_fan_speed_level(self, level: int): if not self.is_fan: raise KasaException("Device is not a Fan") await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) - - @property - def sleep_mode(self) -> bool: - """Return sleep mode status.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return cast(FanModule, self.modules["FanModule"]).sleep_mode - - async def set_sleep_mode(self, on: bool): - """Set sleep mode.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await cast(FanModule, self.modules["FanModule"]).set_sleep_mode(on) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 64ad70fa1..9b55c2606 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -61,7 +61,11 @@ def _test_property_getters(): # Skip emeter and time properties # TODO: needs API cleanup, emeter* should probably be removed in favor # of access through features/modules, handling of time* needs decision. - if name.startswith("emeter_") or name.startswith("time"): + if ( + name.startswith("emeter_") + or name.startswith("time") + or name.startswith("fan") + ): continue try: _ = getattr(first, name) From c1cad5691037040248983069f3d23f3aea11b074 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 26 Apr 2024 11:11:54 +0100 Subject: [PATCH 3/9] Add fan interface test --- kasa/tests/smart/modules/test_fan.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 559ffefe0..f97a4cf9f 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,13 +1,14 @@ from pytest_mock import MockerFixture -from kasa import SmartDevice +from kasa import Device +from kasa.smart import SmartDevice from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @fan -async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_speed(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" fan = dev.modules.get("FanModule") assert fan @@ -30,7 +31,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): +async def test_sleep_mode(dev: Device, mocker: MockerFixture): """Test sleep mode feature.""" fan = dev.modules.get("FanModule") assert fan @@ -45,3 +46,16 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): assert fan.sleep_mode is True assert sleep_feature.value is True + + +@fan +async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed on device interface.""" + assert isinstance(dev, SmartDevice) + + assert dev.fan_speed_level != 3 + await dev.set_fan_speed_level(3) + + await dev.update() + + assert dev.fan_speed_level == 3 From f38016c2c82a25e934c6294fc79210c2daa1a9c6 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 26 Apr 2024 11:34:12 +0100 Subject: [PATCH 4/9] Clean up fan interface --- kasa/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/fan.py b/kasa/fan.py index 1b76f0375..c9601b1b7 100644 --- a/kasa/fan.py +++ b/kasa/fan.py @@ -9,14 +9,14 @@ class Fan(ABC): """Interface for a Fan.""" @property + @abstractmethod def is_fan(self) -> bool: """Return True if the device is a fan.""" - return False @property + @abstractmethod def fan_speed_level(self) -> int: """Return fan speed level.""" - raise @abstractmethod async def set_fan_speed_level(self, level: int): From d291326cfde29fa8e5075ca19fd85be6b398c005 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 26 Apr 2024 12:31:08 +0100 Subject: [PATCH 5/9] Add fan_state to Fan interface --- kasa/fan.py | 18 ++++++++++++++++++ kasa/smart/modules/fanmodule.py | 24 ++++++++++++++++++++++++ kasa/smart/smartdevice.py | 19 ++++++++++++++++++- kasa/tests/smart/modules/test_fan.py | 10 +++++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/kasa/fan.py b/kasa/fan.py index c9601b1b7..78ce73d52 100644 --- a/kasa/fan.py +++ b/kasa/fan.py @@ -3,6 +3,15 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class FanState: + """Class to represent the current state of the Fan.""" + + is_on: bool + speed_level: int class Fan(ABC): @@ -13,6 +22,15 @@ class Fan(ABC): def is_fan(self) -> bool: """Return True if the device is a fan.""" + @property + @abstractmethod + def fan_state(self) -> FanState: + """Return fan state.""" + + @abstractmethod + async def set_fan_state(self, fan_on: bool | None, speed_level: int | None): + """Set fan state.""" + @property @abstractmethod def fan_speed_level(self) -> int: diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 13f35aea8..f49a219df 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING +from ...exceptions import KasaException +from ...fan import FanState from ...feature import Feature from ..smartmodule import SmartModule @@ -49,6 +51,28 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} + @property + def fan_state(self) -> FanState: + """Return fan state.""" + return FanState( + is_on=self.data["device_on"], speed_level=self.data["fan_speed_level"] + ) + + async def set_fan_state( + self, fan_on: bool | None = None, speed_level: int | None = None + ): + """Set fan state.""" + if fan_on is None and speed_level is None: + raise KasaException("Must provide at least one state.") + if speed_level and (speed_level < 1 or speed_level > 4): + raise ValueError("Invalid speed level, should be in range 1-4.") + params: dict[str, bool | int] = {} + if fan_on is not None: + params["device_on"] = fan_on + if speed_level is not None: + params["fan_speed_level"] = speed_level + return await self.call("set_device_info", params) + @property def fan_speed_level(self) -> int: """Return fan speed level.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index dbea53dd5..1288337d0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -13,7 +13,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..fan import Fan +from ..fan import Fan, FanState from ..feature import Feature from ..smartprotocol import SmartProtocol from .modules import ( @@ -616,6 +616,23 @@ def is_fan(self) -> bool: """Return True if the device is a fan.""" return "FanModule" in self.modules + @property + def fan_state(self) -> FanState: + """Return fan speed level.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + return cast(FanModule, self.modules["FanModule"]).fan_state + + async def set_fan_state( + self, fan_on: bool | None = None, speed_level: int | None = None + ): + """Set fan speed level.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + await cast(FanModule, self.modules["FanModule"]).set_fan_state( + fan_on=fan_on, speed_level=speed_level + ) + @property def fan_speed_level(self) -> int: """Return fan speed level.""" diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index f97a4cf9f..547d89de9 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -52,10 +52,18 @@ async def test_sleep_mode(dev: Device, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) + await dev.set_fan_state(fan_on=True, speed_level=1) + await dev.update() + + assert dev.fan_state.is_on is True + assert dev.fan_state.speed_level == 1 - assert dev.fan_speed_level != 3 await dev.set_fan_speed_level(3) await dev.update() assert dev.fan_speed_level == 3 + + await dev.set_fan_state(fan_on=False) + await dev.update() + assert dev.fan_state.is_on is False From bff2bcbfdca3628d43f1f1441a7464c24c376274 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 30 Apr 2024 15:10:09 +0100 Subject: [PATCH 6/9] Remove on_off state from fan interface --- kasa/fan.py | 18 -------- kasa/smart/modules/fanmodule.py | 37 ++++------------ kasa/smart/smartdevice.py | 44 ++++++++++---------- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_fan.py | 29 ++++++------- 5 files changed, 45 insertions(+), 85 deletions(-) diff --git a/kasa/fan.py b/kasa/fan.py index 78ce73d52..c9601b1b7 100644 --- a/kasa/fan.py +++ b/kasa/fan.py @@ -3,15 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass - - -@dataclass -class FanState: - """Class to represent the current state of the Fan.""" - - is_on: bool - speed_level: int class Fan(ABC): @@ -22,15 +13,6 @@ class Fan(ABC): def is_fan(self) -> bool: """Return True if the device is a fan.""" - @property - @abstractmethod - def fan_state(self) -> FanState: - """Return fan state.""" - - @abstractmethod - async def set_fan_state(self, fan_on: bool | None, speed_level: int | None): - """Set fan state.""" - @property @abstractmethod def fan_speed_level(self) -> int: diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index f49a219df..41c150f0f 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from ...exceptions import KasaException -from ...fan import FanState from ...feature import Feature from ..smartmodule import SmartModule @@ -30,7 +29,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, - minimum_value=1, + minimum_value=0, maximum_value=4, category=Feature.Category.Primary, ) @@ -51,38 +50,20 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} - @property - def fan_state(self) -> FanState: - """Return fan state.""" - return FanState( - is_on=self.data["device_on"], speed_level=self.data["fan_speed_level"] - ) - - async def set_fan_state( - self, fan_on: bool | None = None, speed_level: int | None = None - ): - """Set fan state.""" - if fan_on is None and speed_level is None: - raise KasaException("Must provide at least one state.") - if speed_level and (speed_level < 1 or speed_level > 4): - raise ValueError("Invalid speed level, should be in range 1-4.") - params: dict[str, bool | int] = {} - if fan_on is not None: - params["device_on"] = fan_on - if speed_level is not None: - params["fan_speed_level"] = speed_level - return await self.call("set_device_info", params) - @property def fan_speed_level(self) -> int: """Return fan speed level.""" return self.data["fan_speed_level"] async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if level < 1 or level > 4: - raise ValueError("Invalid level, should be in range 1-4.") - return await self.call("set_device_info", {"fan_speed_level": level}) + """Set fan speed level, 0 for off, 1-4 for on.""" + if level < 0 or level > 4: + raise KasaException("Invalid level, should be in range 0-4.") + if level == 0: + return await self.call("set_device_info", {"device_on": False}) + return await self.call( + "set_device_info", {"device_on": True, "fan_speed_level": level} + ) @property def sleep_mode(self) -> bool: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 725a50688..318222827 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,7 +14,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..fan import Fan, FanState +from ..fan import Fan from ..feature import Feature from ..smartprotocol import SmartProtocol from .modules import ( @@ -223,9 +223,6 @@ async def _initialize_modules(self): if await module._check_supported(): self._modules[module.name] = module - if self._exposes_child_modules: - self._modules.update(**child_modules_to_skip) - async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -310,6 +307,26 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) + def has_module(self, module_name): + """Return true if the device has the module.""" + if module_name in self.modules: + return True + elif self._exposes_child_modules: + for child in self._children.values(): + if module_name in child.modules: + return True + return False + + def get_module(self, module_name): + """Return the module from the device modules.""" + 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 False + @property def is_cloud_connected(self): """Returns if the device is connected to the cloud.""" @@ -636,28 +653,13 @@ def _get_device_type_from_components( _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_state(self) -> FanState: - """Return fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return cast(FanModule, self.modules["FanModule"]).fan_state - - async def set_fan_state( - self, fan_on: bool | None = None, speed_level: int | None = None - ): - """Set fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await cast(FanModule, self.modules["FanModule"]).set_fan_state( - fan_on=fan_on, speed_level=speed_level - ) - @property def fan_speed_level(self) -> int: """Return fan speed level.""" diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index d677725d8..79df0abf9 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,7 +10,7 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.modules.get("Brightness") + brightness = dev.get_module("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 86f9129f9..e4d08ac74 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -2,7 +2,6 @@ from pytest_mock import MockerFixture -from kasa import Device from kasa.smart import SmartDevice from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize @@ -11,9 +10,9 @@ @fan -async def test_fan_speed(dev: Device, mocker: MockerFixture): +async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = cast(FanModule, dev.modules.get("FanModule")) + fan = cast(FanModule, dev.get_module("FanModule")) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -25,7 +24,9 @@ async def test_fan_speed(dev: Device, mocker: MockerFixture): call = mocker.spy(fan, "call") await fan.set_fan_speed_level(3) - call.assert_called_with("set_device_info", {"fan_speed_level": 3}) + call.assert_called_with( + "set_device_info", {"device_on": True, "fan_speed_level": 3} + ) await dev.update() @@ -34,9 +35,9 @@ async def test_fan_speed(dev: Device, mocker: MockerFixture): @fan -async def test_sleep_mode(dev: Device, mocker: MockerFixture): +async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = cast(FanModule, dev.modules.get("FanModule")) + fan = cast(FanModule, dev.get_module("FanModule")) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -55,18 +56,12 @@ async def test_sleep_mode(dev: Device, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - await dev.set_fan_state(fan_on=True, speed_level=1) - await dev.update() - - assert dev.fan_state.is_on is True - assert dev.fan_state.speed_level == 1 + fan = cast(FanModule, dev.get_module("FanModule")) + device = fan._device + assert device.is_fan - await dev.set_fan_speed_level(3) + await device.set_fan_speed_level(3) await dev.update() - assert dev.fan_speed_level == 3 - - await dev.set_fan_state(fan_on=False) - await dev.update() - assert dev.fan_state.is_on is False + assert device.fan_speed_level == 3 From a1a91e8d41b6acfe1bbac7c0d84ca7844e9363a4 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 30 Apr 2024 15:52:47 +0100 Subject: [PATCH 7/9] Remove has_module and have get_module return none --- kasa/smart/smartdevice.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ef2939d0a..04c2607be 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -308,25 +308,15 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) - def has_module(self, module_name): - """Return true if the device has the module.""" - if module_name in self.modules: - return True - elif self._exposes_child_modules: - for child in self._children.values(): - if module_name in child.modules: - return True - return False - - def get_module(self, module_name): - """Return the module from the device modules.""" + def get_module(self, module_name) -> SmartModule | None: + """Return the module from the device modules or None if not present.""" 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 False + return None @property def is_cloud_connected(self): From a677cb16e41dc946823e4115494aa4b431fcf13a Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 30 Apr 2024 16:05:09 +0100 Subject: [PATCH 8/9] Add out of range test --- kasa/tests/smart/modules/test_fan.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index e4d08ac74..93e33f755 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,7 +1,9 @@ from typing import cast +import pytest from pytest_mock import MockerFixture +from kasa import KasaException from kasa.smart import SmartDevice from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize @@ -60,8 +62,21 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): device = fan._device assert device.is_fan - await device.set_fan_speed_level(3) + await device.set_fan_speed_level(1) + await dev.update() + assert device.fan_speed_level == 1 + assert device.is_on + + await device.set_fan_speed_level(4) + await dev.update() + assert device.fan_speed_level == 4 + await device.set_fan_speed_level(0) await dev.update() + assert not device.is_on + + with pytest.raises(KasaException): + await device.set_fan_speed_level(-1) - assert device.fan_speed_level == 3 + with pytest.raises(KasaException): + await device.set_fan_speed_level(5) From 8dd144b6ebdf555fbb78e61a241dff1c1f2e0c07 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 30 Apr 2024 16:41:54 +0100 Subject: [PATCH 9/9] Raise ValueError and add test for get_module --- kasa/smart/modules/fanmodule.py | 3 +-- kasa/tests/smart/modules/test_fan.py | 5 ++--- kasa/tests/test_smartdevice.py | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 41c150f0f..08a681e7e 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING -from ...exceptions import KasaException from ...feature import Feature from ..smartmodule import SmartModule @@ -58,7 +57,7 @@ def fan_speed_level(self) -> int: async def set_fan_speed_level(self, level: int): """Set fan speed level, 0 for off, 1-4 for on.""" if level < 0 or level > 4: - raise KasaException("Invalid level, should be in range 0-4.") + raise ValueError("Invalid level, should be in range 0-4.") if level == 0: return await self.call("set_device_info", {"device_on": False}) return await self.call( diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 93e33f755..429a5d18f 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -3,7 +3,6 @@ import pytest from pytest_mock import MockerFixture -from kasa import KasaException from kasa.smart import SmartDevice from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize @@ -75,8 +74,8 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on - with pytest.raises(KasaException): + with pytest.raises(ValueError): await device.set_fan_speed_level(-1) - with pytest.raises(KasaException): + with pytest.raises(ValueError): await device.set_fan_speed_level(5) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2dc27ac46..476a37ae5 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -16,6 +16,7 @@ from .conftest import ( bulb_smart, device_smart, + get_device_for_fixture_protocol, ) @@ -121,6 +122,24 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +async def test_get_modules(mocker): + """Test get_modules for child and parent modules.""" + dummy_device = await get_device_for_fixture_protocol( + "KS240(US)_1.0_1.0.5.json", "SMART" + ) + module = dummy_device.get_module("CloudModule") + assert module + assert module._device == dummy_device + + module = dummy_device.get_module("FanModule") + assert module + assert module._device != dummy_device + assert module._device._parent == dummy_device + + module = dummy_device.get_module("DummyModule") + assert module is None + + @bulb_smart async def test_smartdevice_brightness(dev: SmartDevice): """Test brightness setter and getter."""