mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
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.
This commit is contained in:
parent
7db989e2ec
commit
16f17a7729
23
kasa/fan.py
Normal file
23
kasa/fan.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Module for Fan Interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class Fan(ABC):
|
||||
"""Interface for a Fan."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_fan(self) -> bool:
|
||||
"""Return True if the device is a fan."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def fan_speed_level(self) -> int:
|
||||
"""Return fan speed level."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_fan_speed_level(self, level: int):
|
||||
"""Set fan speed level."""
|
@ -28,7 +28,7 @@ class FanModule(SmartModule):
|
||||
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,
|
||||
)
|
||||
@ -55,10 +55,14 @@ class FanModule(SmartModule):
|
||||
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 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(
|
||||
"set_device_info", {"device_on": True, "fan_speed_level": level}
|
||||
)
|
||||
|
||||
@property
|
||||
def sleep_mode(self) -> bool:
|
||||
|
@ -14,6 +14,7 @@ from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..emeterstatus import EmeterStatus
|
||||
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
||||
from ..fan import Fan
|
||||
from ..feature import Feature
|
||||
from ..smartprotocol import SmartProtocol
|
||||
from .modules import (
|
||||
@ -23,6 +24,7 @@ from .modules import (
|
||||
ColorTemperatureModule,
|
||||
DeviceModule,
|
||||
EnergyModule,
|
||||
FanModule,
|
||||
Firmware,
|
||||
TimeModule,
|
||||
)
|
||||
@ -36,7 +38,7 @@ if TYPE_CHECKING:
|
||||
# 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]
|
||||
|
||||
AVAILABLE_BULB_EFFECTS = {
|
||||
"L1": "Party",
|
||||
@ -44,7 +46,7 @@ AVAILABLE_BULB_EFFECTS = {
|
||||
}
|
||||
|
||||
|
||||
class SmartDevice(Device, Bulb):
|
||||
class SmartDevice(Device, Bulb, Fan):
|
||||
"""Base class to represent a SMART protocol based device."""
|
||||
|
||||
def __init__(
|
||||
@ -221,9 +223,6 @@ class SmartDevice(Device, Bulb):
|
||||
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(
|
||||
@ -309,6 +308,16 @@ class SmartDevice(Device, Bulb):
|
||||
for feat in module._module_features.values():
|
||||
self._add_feature(feat)
|
||||
|
||||
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 None
|
||||
|
||||
@property
|
||||
def is_cloud_connected(self):
|
||||
"""Returns if the device is connected to the cloud."""
|
||||
@ -460,19 +469,19 @@ class SmartDevice(Device, Bulb):
|
||||
@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
|
||||
@ -635,6 +644,26 @@ class SmartDevice(Device, Bulb):
|
||||
_LOGGER.warning("Unknown device type, falling back to plug")
|
||||
return DeviceType.Plug
|
||||
|
||||
# Fan interface methods
|
||||
|
||||
@property
|
||||
def is_fan(self) -> bool:
|
||||
"""Return True if the device is a fan."""
|
||||
return "FanModule" in self.modules
|
||||
|
||||
@property
|
||||
def fan_speed_level(self) -> int:
|
||||
"""Return fan speed level."""
|
||||
if not self.is_fan:
|
||||
raise KasaException("Device is not a Fan")
|
||||
return cast(FanModule, self.modules["FanModule"]).fan_speed_level
|
||||
|
||||
async def set_fan_speed_level(self, level: int):
|
||||
"""Set fan speed level."""
|
||||
if not self.is_fan:
|
||||
raise KasaException("Device is not a Fan")
|
||||
await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level)
|
||||
|
||||
# Bulb interface methods
|
||||
|
||||
@property
|
||||
|
@ -10,7 +10,7 @@ brightness = parametrize("brightness smart", component_filter="brightness")
|
||||
@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
|
||||
|
@ -1,8 +1,9 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import SmartDevice
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules import FanModule
|
||||
from kasa.tests.device_fixtures import parametrize
|
||||
|
||||
@ -12,7 +13,7 @@ fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"S
|
||||
@fan
|
||||
async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test fan speed feature."""
|
||||
fan = cast(FanModule, dev.modules.get("FanModule"))
|
||||
fan = cast(FanModule, dev.get_module("FanModule"))
|
||||
assert fan
|
||||
|
||||
level_feature = fan._module_features["fan_speed_level"]
|
||||
@ -24,7 +25,9 @@ async def test_fan_speed(dev: SmartDevice, 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()
|
||||
|
||||
@ -35,7 +38,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
|
||||
@fan
|
||||
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)
|
||||
@ -48,3 +51,31 @@ 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)
|
||||
fan = cast(FanModule, dev.get_module("FanModule"))
|
||||
device = fan._device
|
||||
assert device.is_fan
|
||||
|
||||
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(ValueError):
|
||||
await device.set_fan_speed_level(-1)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await device.set_fan_speed_level(5)
|
||||
|
@ -16,6 +16,7 @@ from kasa.smart import SmartDevice
|
||||
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."""
|
||||
|
Loading…
Reference in New Issue
Block a user