Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions kasa/fan.py
Original file line number Diff line number Diff line change
@@ -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."""
14 changes: 9 additions & 5 deletions kasa/smart/modules/fanmodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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,
)
Expand All @@ -55,10 +55,14 @@ def fan_speed_level(self) -> int:
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:
Expand Down
45 changes: 37 additions & 8 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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 (
Expand All @@ -23,6 +24,7 @@
ColorTemperatureModule,
DeviceModule,
EnergyModule,
FanModule,
Firmware,
TimeModule,
)
Expand All @@ -36,15 +38,15 @@
# 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",
"L2": "Relax",
}


class SmartDevice(Device, Bulb):
class SmartDevice(Device, Bulb, Fan):
"""Base class to represent a SMART protocol based device."""

def __init__(
Expand Down Expand Up @@ -221,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(
Expand Down Expand Up @@ -309,6 +308,16 @@ async def _initialize_features(self):
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."""
Expand Down Expand Up @@ -460,19 +469,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
Expand Down Expand Up @@ -635,6 +644,26 @@ 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_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
Expand Down
2 changes: 1 addition & 1 deletion kasa/tests/smart/features/test_brightness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions kasa/tests/smart/modules/test_fan.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -12,7 +13,7 @@
@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"]
Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -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)
19 changes: 19 additions & 0 deletions kasa/tests/test_smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .conftest import (
bulb_smart,
device_smart,
get_device_for_fixture_protocol,
)


Expand Down Expand Up @@ -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."""
Expand Down