diff --git a/kasa/__init__.py b/kasa/__init__.py index 059e093e..d4a5022e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -36,6 +36,7 @@ from kasa.exceptions import ( ) from kasa.feature import Feature from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState +from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.module import Module from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 @@ -72,6 +73,8 @@ __all__ = [ "DeviceConnectionParameters", "DeviceEncryptionType", "DeviceFamily", + "ThermostatState", + "Thermostat", ] from . import iot diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index c83e56c7..e5fd4cae 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -6,6 +6,7 @@ from .led import Led from .light import Light, LightState from .lighteffect import LightEffect from .lightpreset import LightPreset +from .thermostat import Thermostat, ThermostatState from .time import Time __all__ = [ @@ -16,5 +17,7 @@ __all__ = [ "LightEffect", "LightState", "LightPreset", + "Thermostat", + "ThermostatState", "Time", ] diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py new file mode 100644 index 00000000..de7831b0 --- /dev/null +++ b/kasa/interfaces/thermostat.py @@ -0,0 +1,65 @@ +"""Interact with a TPLink Thermostat.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Annotated, Literal + +from ..module import FeatureAttribute, Module + + +class ThermostatState(Enum): + """Thermostat state.""" + + Heating = "heating" + Calibrating = "progress_calibration" + Idle = "idle" + Off = "off" + Unknown = "unknown" + + +class Thermostat(Module, ABC): + """Base class for TP-Link Thermostat.""" + + @property + @abstractmethod + def state(self) -> bool: + """Return thermostat state.""" + + @abstractmethod + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + + @property + @abstractmethod + def mode(self) -> ThermostatState: + """Return thermostat state.""" + + @property + @abstractmethod + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + + @abstractmethod + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + + @property + @abstractmethod + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + @abstractmethod + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + + @abstractmethod + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" diff --git a/kasa/module.py b/kasa/module.py index ba6791b0..2b2e65f9 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -96,6 +96,7 @@ class Module(ABC): Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") + Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat") Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") # IOT only Modules diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index efe17aa4..99820cfa 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -27,6 +27,7 @@ from .motionsensor import MotionSensor from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor +from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs from .waterleaksensor import WaterleakSensor @@ -61,5 +62,6 @@ __all__ = [ "MotionSensor", "TriggerLogs", "FrostProtection", + "Thermostat", "SmartLightEffect", ] diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 138c3d2e..5b080461 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -3,24 +3,14 @@ from __future__ import annotations import logging -from enum import Enum from ...feature import Feature +from ...interfaces.thermostat import ThermostatState from ..smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) -class ThermostatState(Enum): - """Thermostat state.""" - - Heating = "heating" - Calibrating = "progress_calibration" - Idle = "idle" - Off = "off" - Unknown = "unknown" - - class TemperatureControl(SmartModule): """Implementation of temperature module.""" @@ -56,7 +46,6 @@ class TemperatureControl(SmartModule): category=Feature.Category.Config, ) ) - self._add_feature( Feature( self._device, @@ -69,7 +58,6 @@ class TemperatureControl(SmartModule): type=Feature.Type.Switch, ) ) - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/thermostat.py b/kasa/smart/modules/thermostat.py new file mode 100644 index 00000000..74aad4be --- /dev/null +++ b/kasa/smart/modules/thermostat.py @@ -0,0 +1,74 @@ +"""Module for a Thermostat.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from ...feature import Feature +from ...interfaces.thermostat import Thermostat as ThermostatInterface +from ...interfaces.thermostat import ThermostatState +from ...module import FeatureAttribute, Module +from ..smartmodule import SmartModule + + +class Thermostat(SmartModule, ThermostatInterface): + """Implementation of a Thermostat.""" + + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if temp_control := self._device.modules.get(Module.TemperatureControl): + ret.update(**temp_control._module_features) + if temp_sensor := self._device.modules.get(Module.TemperatureSensor): + ret.update(**temp_sensor._module_features) + return ret + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def state(self) -> bool: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].state + + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + return await self._device.modules[Module.TemperatureControl].set_state(enabled) + + @property + def mode(self) -> ThermostatState: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].mode + + @property + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + return self._device.modules[Module.TemperatureControl].target_temperature + + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + return await self._device.modules[ + Module.TemperatureControl + ].set_target_temperature(target) + + @property + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.modules[Module.TemperatureSensor].temperature + + @property + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + return self._device.modules[Module.TemperatureSensor].temperature_unit + + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" + return await self._device.modules[ + Module.TemperatureSensor + ].set_temperature_unit(unit) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index bd0ea7c5..0989842a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -24,6 +24,7 @@ from .modules import ( DeviceModule, Firmware, Light, + Thermostat, Time, ) from .smartmodule import SmartModule @@ -361,6 +362,11 @@ class SmartDevice(Device): or Module.ColorTemperature in self._modules ): self._modules[Light.__name__] = Light(self, "light") + if ( + Module.TemperatureControl in self._modules + and Module.TemperatureSensor in self._modules + ): + self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") async def _initialize_features(self) -> None: """Initialize device features.""" diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 2f4e6ec2..448729ca 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -449,6 +449,17 @@ class FakeSmartTransport(BaseTransport): info["get_preset_rules"]["states"][params["index"]] = params["state"] return {"error_code": 0} + def _set_temperature_unit(self, info, params): + """Set or remove values as per the device behaviour.""" + unit = params["temp_unit"] + if unit not in {"celsius", "fahrenheit"}: + raise ValueError(f"Invalid value for temperature unit {unit}") + if "temp_unit" not in info["get_device_info"]: + return {"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR} + else: + info["get_device_info"]["temp_unit"] = unit + return {"error_code": 0} + def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: """Update a single key in the main system info. @@ -551,6 +562,8 @@ class FakeSmartTransport(BaseTransport): return self._set_preset_rules(info, params) elif method == "edit_preset_rules": return self._edit_preset_rules(info, params) + elif method == "set_temperature_unit": + return self._set_temperature_unit(info, params) elif method == "set_on_off_gradually_info": return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index bd6189c5..32863604 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo import pytest from pytest_mock import MockerFixture -from kasa import Device, LightState, Module +from kasa import Device, LightState, Module, ThermostatState from .device_fixtures import ( bulb_iot, @@ -57,6 +57,12 @@ light_preset = parametrize_combine([light_preset_smart, bulb_iot]) light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) +temp_control_smart = parametrize( + "has temp control smart", + component_filter="temp_control", + protocol_filter={"SMART.CHILD"}, +) + @led async def test_led_module(dev: Device, mocker: MockerFixture): @@ -325,6 +331,39 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture): assert new_preset_state.color_temp == new_preset.color_temp +@temp_control_smart +async def test_thermostat(dev: Device, mocker: MockerFixture): + """Test saving a new preset value.""" + therm_mod = next(get_parent_and_child_modules(dev, Module.Thermostat)) + assert therm_mod + + await therm_mod.set_state(False) + await dev.update() + assert therm_mod.state is False + assert therm_mod.mode is ThermostatState.Off + + await therm_mod.set_target_temperature(10) + await dev.update() + assert therm_mod.state is True + assert therm_mod.mode is ThermostatState.Heating + assert therm_mod.target_temperature == 10 + + target_temperature_feature = therm_mod.get_feature(therm_mod.set_target_temperature) + temp_control = dev.modules.get(Module.TemperatureControl) + assert temp_control + allowed_range = temp_control.allowed_temperature_range + assert target_temperature_feature.minimum_value == allowed_range[0] + assert target_temperature_feature.maximum_value == allowed_range[1] + + await therm_mod.set_temperature_unit("celsius") + await dev.update() + assert therm_mod.temperature_unit == "celsius" + + await therm_mod.set_temperature_unit("fahrenheit") + await dev.update() + assert therm_mod.temperature_unit == "fahrenheit" + + async def test_set_time(dev: Device): """Test setting the device time.""" time_mod = dev.modules[Module.Time]