Skip to content
Closed
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
41 changes: 41 additions & 0 deletions kasa/fan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Module for Fan Interface."""

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):
"""Interface for a Fan."""

@property
@abstractmethod
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:
"""Return fan speed level."""

@abstractmethod
async def set_fan_speed_level(self, level: int):
"""Set fan speed level."""
24 changes: 24 additions & 0 deletions kasa/smart/modules/fanmodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
55 changes: 49 additions & 6 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..fan import Fan, FanState
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__)

Expand All @@ -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__(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -602,3 +610,38 @@ 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_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."""
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)
28 changes: 25 additions & 3 deletions kasa/tests/smart/modules/test_fan.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -45,3 +46,24 @@ 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)
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

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
6 changes: 5 additions & 1 deletion kasa/tests/test_childdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down