Add common Thermostat module (#977)

This commit is contained in:
Steven B. 2024-11-26 09:37:15 +00:00 committed by GitHub
parent cb4e28394d
commit 3dfada7575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 208 additions and 14 deletions

View File

@ -36,6 +36,7 @@ from kasa.exceptions import (
) )
from kasa.feature import Feature from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module from kasa.module import Module
from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
@ -72,6 +73,8 @@ __all__ = [
"DeviceConnectionParameters", "DeviceConnectionParameters",
"DeviceEncryptionType", "DeviceEncryptionType",
"DeviceFamily", "DeviceFamily",
"ThermostatState",
"Thermostat",
] ]
from . import iot from . import iot

View File

@ -6,6 +6,7 @@ from .led import Led
from .light import Light, LightState from .light import Light, LightState
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightpreset import LightPreset from .lightpreset import LightPreset
from .thermostat import Thermostat, ThermostatState
from .time import Time from .time import Time
__all__ = [ __all__ = [
@ -16,5 +17,7 @@ __all__ = [
"LightEffect", "LightEffect",
"LightState", "LightState",
"LightPreset", "LightPreset",
"Thermostat",
"ThermostatState",
"Time", "Time",
] ]

View File

@ -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."""

View File

@ -96,6 +96,7 @@ class Module(ABC):
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat")
Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time")
# IOT only Modules # IOT only Modules

View File

@ -27,6 +27,7 @@ from .motionsensor import MotionSensor
from .reportmode import ReportMode from .reportmode import ReportMode
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
from .temperaturesensor import TemperatureSensor from .temperaturesensor import TemperatureSensor
from .thermostat import Thermostat
from .time import Time from .time import Time
from .triggerlogs import TriggerLogs from .triggerlogs import TriggerLogs
from .waterleaksensor import WaterleakSensor from .waterleaksensor import WaterleakSensor
@ -61,5 +62,6 @@ __all__ = [
"MotionSensor", "MotionSensor",
"TriggerLogs", "TriggerLogs",
"FrostProtection", "FrostProtection",
"Thermostat",
"SmartLightEffect", "SmartLightEffect",
] ]

View File

@ -3,24 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from enum import Enum
from ...feature import Feature from ...feature import Feature
from ...interfaces.thermostat import ThermostatState
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ThermostatState(Enum):
"""Thermostat state."""
Heating = "heating"
Calibrating = "progress_calibration"
Idle = "idle"
Off = "off"
Unknown = "unknown"
class TemperatureControl(SmartModule): class TemperatureControl(SmartModule):
"""Implementation of temperature module.""" """Implementation of temperature module."""
@ -56,7 +46,6 @@ class TemperatureControl(SmartModule):
category=Feature.Category.Config, category=Feature.Category.Config,
) )
) )
self._add_feature( self._add_feature(
Feature( Feature(
self._device, self._device,
@ -69,7 +58,6 @@ class TemperatureControl(SmartModule):
type=Feature.Type.Switch, type=Feature.Type.Switch,
) )
) )
self._add_feature( self._add_feature(
Feature( Feature(
self._device, self._device,

View File

@ -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)

View File

@ -24,6 +24,7 @@ from .modules import (
DeviceModule, DeviceModule,
Firmware, Firmware,
Light, Light,
Thermostat,
Time, Time,
) )
from .smartmodule import SmartModule from .smartmodule import SmartModule
@ -361,6 +362,11 @@ class SmartDevice(Device):
or Module.ColorTemperature in self._modules or Module.ColorTemperature in self._modules
): ):
self._modules[Light.__name__] = Light(self, "light") 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: async def _initialize_features(self) -> None:
"""Initialize device features.""" """Initialize device features."""

View File

@ -449,6 +449,17 @@ class FakeSmartTransport(BaseTransport):
info["get_preset_rules"]["states"][params["index"]] = params["state"] info["get_preset_rules"]["states"][params["index"]] = params["state"]
return {"error_code": 0} 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: def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict:
"""Update a single key in the main system info. """Update a single key in the main system info.
@ -551,6 +562,8 @@ class FakeSmartTransport(BaseTransport):
return self._set_preset_rules(info, params) return self._set_preset_rules(info, params)
elif method == "edit_preset_rules": elif method == "edit_preset_rules":
return self._edit_preset_rules(info, params) 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": elif method == "set_on_off_gradually_info":
return self._set_on_off_gradually_info(info, params) return self._set_on_off_gradually_info(info, params)
elif method == "set_child_protection": elif method == "set_child_protection":

View File

@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, LightState, Module from kasa import Device, LightState, Module, ThermostatState
from .device_fixtures import ( from .device_fixtures import (
bulb_iot, bulb_iot,
@ -57,6 +57,12 @@ light_preset = parametrize_combine([light_preset_smart, bulb_iot])
light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) 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 @led
async def test_led_module(dev: Device, mocker: MockerFixture): 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 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): async def test_set_time(dev: Device):
"""Test setting the device time.""" """Test setting the device time."""
time_mod = dev.modules[Module.Time] time_mod = dev.modules[Module.Time]