8000 Add Fan interface for SMART devices (#873) · python-kasa/python-kasa@16f17a7 · GitHub
[go: up one dir, main page]

Skip to content

Commit 16f17a7

Browse files
authored
Add Fan interface for SMART devices (#873)
Enables the Fan interface for devices supporting that component. Currently the only device with a fan is the ks240 which implements it as a child device. This PR adds a method `get_module` to search the child device for modules if it is a WallSwitch device type.
1 parent 7db989e commit 16f17a7

File tree

6 files changed

+124
-18
lines changed

6 files changed

+124
-18
lines changed

kasa/fan.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Module for Fan Interface."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
7+
8+
class Fan(ABC):
9+
"""Interface for a Fan."""
10+
11+
@property
12+
@abstractmethod
13+
def is_fan(self) -> bool:
14+
"""Return True if the device is a fan."""
15+
16+
@property
17+
@abstractmethod
18+
def fan_speed_level(self) -> int:
19+
"""Return fan speed level."""
20+
21+
@abstractmethod
22+
async def set_fan_speed_level(self, level: int):
23+
"""Set fan speed level."""

kasa/smart/modules/fanmodule.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def __init__(self, device: SmartDevice, module: str):
2828
attribute_setter="set_fan_speed_level",
2929
icon="mdi:fan",
3030
type=Feature.Type.Number,
31-
minimum_value=1,
31+
minimum_value=0,
3232
maximum_value=4,
3333
category=Feature.Category.Primary,
3434
)
@@ -55,10 +55,14 @@ def fan_speed_level(self) -> int:
5555
return self.data["fan_speed_level"]
5656

5757
async def set_fan_speed_level(self, level: int):
58-
"""Set fan speed level."""
59-
if level < 1 or level > 4:
60-
raise ValueError("Invalid level, should be in range 1-4.")
61-
return await self.call("set_device_info", {"fan_speed_level": level})
58+
"""Set fan speed level, 0 for off, 1-4 for on."""
59+
if level < 0 or level > 4:
60+
raise ValueError("Invalid level, should be in range 0-4.")
61+
if level == 0:
62+
return await self.call("set_device_info", {"device_on": False})
63+
return await self.call(
64+
"set_device_info", {"device_on": True, "fan_speed_level": level}
65+
)
6266

6367
@property
6468
def sleep_mode(self) -> bool:

kasa/smart/smartdevice.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ..deviceconfig import DeviceConfig
1515
from ..emeterstatus import EmeterStatus
1616
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
17+
from ..fan import Fan
1718
from ..feature import Feature
1819
from ..smartprotocol import SmartProtocol
1920
from .modules import (
@@ -23,6 +24,7 @@
2324
ColorTemperatureModule,
2425
DeviceModule,
2526
EnergyModule,
27+
FanModule,
2628
Firmware,
27 9E88 29
TimeModule,
2830
)
@@ -36,15 +38,15 @@
3638
# the child but only work on the parent. See longer note below in _initialize_modules.
3739
# This list should be updated when creating new modules that could have the
3840
# same issue, homekit perhaps?
39-
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405
41+
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule]
4042

4143
AVAILABLE_BULB_EFFECTS = {
4244
"L1": "Party",
4345
"L2": "Relax",
4446
}
4547

4648

47-
class SmartDevice(Device, Bulb):
49+
class SmartDevice(Device, Bulb, Fan):
4850
"""Base class to represent a SMART protocol based device."""
4951

5052
def __init__(
@@ -221,9 +223,6 @@ async def _initialize_modules(self):
221223
if await module._check_supported():
222224
self._modules[module.name] = module
223225

224-
if self._exposes_child_modules:
225-
self._modules.update(**child_modules_to_skip)
226-
227226
async def _initialize_features(self):
228227
"""Initialize device features."""
229228
self._add_feature(
@@ -309,6 +308,16 @@ async def _initialize_features(self):
309308
for feat in module._module_features.values():
310309
self._add_feature(feat)
311310

311+
def get_module(self, module_name) -> SmartModule | None:
312+
"""Return the module from the device modules or None if not present."""
313+
if module_name in self.modules:
314+
return self.modules[module_name]
315+
elif self._exposes_child_modules:
316+
for child in self._children.values():
317+
if module_name in child.modules:
318+
return child.modules[module_name]
319+
return None
320+
312321
@property
313322
def is_cloud_connected(self):
314323
"""Returns if the device is connected to the cloud."""
@@ -460,19 +469,19 @@ async def get_emeter_realtime(self) -> EmeterStatus:
460469
@property
461470
def emeter_realtime(self) -> EmeterStatus:
462471
"""Get the emeter status."""
463-
energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405
472+
energy = cast(EnergyModule, self.modules["EnergyModule"])
464473
return energy.emeter_realtime
465474

466475
@property
467476
def emeter_this_month(self) -> float | None:
468477
"""Get the emeter value for this month."""
469-
energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405
478+
energy = cast(EnergyModule, self.modules["EnergyModule"])
470479
return energy.emeter_this_month
471480

472481
@property
473482
def emeter_today(self) -> float | None:
474483
"""Get the emeter value for today."""
475-
energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405
484+
energy = cast(EnergyModule, self.modules["EnergyModule"])
476485
return energy.emeter_today
477486

478487
@property
@@ -635,6 +644,26 @@ def _get_device_type_from_components(
635644
_LOGGER.warning("Unknown device type, falling back to plug")
636645
return DeviceType.Plug
637646

647+
# Fan interface methods
648+
649+
@property
650+
def is_fan(self) -> bool:
651+
"""Return True if the device is a fan."""
652+
return "FanModule" in self.modules
653+
654+
@property
655+
def fan_speed_level(self) -> int:
656+
"""Return fan speed level."""
657+
if not self.is_fan:
658+
raise KasaException("Device is not a Fan")
659+
return cast(FanModule, self.modules["FanModule"]).fan_speed_level
660+
661+
async def set_fan_speed_level(self, level: int):
662+
"""Set fan speed level."""
663+
if not self.is_fan:
664+
raise KasaException("Device is not a Fan")
665+
await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level)
666+
638667
# Bulb interface methods
639668

640669
@property

kasa/tests/smart/features/test_brightness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
@brightness
1111
async def test_brightness_component(dev: SmartDevice):
1212
"""Test brightness feature."""
13-
brightness = dev.modules.get("Brightness")
13+
brightness = dev.get_module("Brightness")
1414
assert brightness
1515
assert isinstance(dev, SmartDevice)
1616
assert "brightness" in dev._components

kasa/tests/smart/modules/test_fan.py

< F987 span class="sr-only">Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import cast
22

3+
import pytest
34
from pytest_mock import MockerFixture
45

5-
from kasa import SmartDevice
6+
from kasa.smart import SmartDevice
67
from kasa.smart.modules import FanModule
78
from kasa.tests.device_fixtures import parametrize
89

@@ -12,7 +13,7 @@
1213
@fan
1314
async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
1415
"""Test fan speed feature."""
15-
fan = cast(FanModule, dev.modules.get("FanModule"))
16+
fan = cast(FanModule, dev.get_module("FanModule"))
1617
assert fan
1718

1819
level_feature = fan._module_features["fan_speed_level"]
@@ -24,7 +25,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
2425

2526
call = mocker.spy(fan, "call")
2627
await fan.set_fan_speed_level(3)
27-
call.assert_called_with("set_device_info", {"fan_speed_level": 3})
28+
call.assert_called_with(
29+
"set_device_info", {"device_on": True, "fan_speed_level": 3}
30+
)
2831

2932
await dev.update()
3033

@@ -35,7 +38,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
3538
@fan
3639
async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
3740
"""Test sleep mode feature."""
38-
fan = cast(FanModule, dev.modules.get("FanModule"))
41+
fan = cast(FanModule, dev.get_module("FanModule"))
3942
assert fan
4043
sleep_feature = fan._module_features["fan_sleep_mode"]
4144
assert isinstance(sleep_feature.value, bool)
@@ -48,3 +51,31 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
4851

4952
assert fan.sleep_mode is True
5053
assert sleep_feature.value is True
54+
55+
56+
@fan
57+
async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture):
58+
"""Test fan speed on device interface."""
59+
assert isinstance(dev, SmartDevice)
60+
fan = cast(FanModule, dev.get_module("FanModule"))
61+
device = fan._device
62+
assert device.is_fan
63+
64+
await device.set_fan_speed_level(1)
65+
await dev.update()
66+
assert device.fan_speed_level == 1
67+
assert device.is_on
68+
69+
await device.set_fan_speed_level(4)
70+
await dev.update()
71+
assert device.fan_speed_level == 4
72+
73+
await device.set_fan_speed_level(0)
74+
await dev.update()
75+
assert not device.is_on
76+
77+
with pytest.raises(ValueError):
78+
await device.set_fan_speed_level(-1)
79+
80+
with pytest.raises(ValueError):
81+
await device.set_fan_speed_level(5)

kasa/tests/test_smartdevice.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .conftest import (
1717
bulb_smart,
1818
device_smart,
19+
get_device_for_fixture_protocol,
1920
)
2021

2122

@@ -121,6 +122,24 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
121122
spies[device].assert_not_called()
122123

123124

125+
async def test_get_modules(mocker):
126+
"""Test get_modules for child and parent modules."""
127+
dummy_device = await get_device_for_fixture_protocol(
128+
"KS240(US)_1.0_1.0.5.json", "SMART"
129+
)
130+
module = dummy_device.get_module("CloudModule")
131+
assert module
132+
assert module._device == dummy_device
133+
134+
module = dummy_device.get_module("FanModule")
135+
assert module
136+
assert module._device != dummy_device
137+
assert module._device._parent == dummy_device
138+
139+
module = dummy_device.get_module("DummyModule")
140+
assert module is None
141+
142+
124143
@bulb_smart
125144
async def test_smartdevice_brightness(dev: SmartDevice):
126145
"""Test brightness setter and getter."""

0 commit comments

Comments
 (0)
0