Skip to content

Add common Thermostat module #977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 26, 2024
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
3 changes: 3 additions & 0 deletions kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
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
Expand Down Expand Up @@ -72,6 +73,8 @@
"DeviceConnectionParameters",
"DeviceEncryptionType",
"DeviceFamily",
"ThermostatState",
"Thermostat",
]

from . import iot
Expand Down
3 changes: 3 additions & 0 deletions kasa/interfaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .light import Light, LightState
from .lighteffect import LightEffect
from .lightpreset import LightPreset
from .thermostat import Thermostat, ThermostatState
from .time import Time

__all__ = [
Expand All @@ -16,5 +17,7 @@
"LightEffect",
"LightState",
"LightPreset",
"Thermostat",
"ThermostatState",
"Time",
]
65 changes: 65 additions & 0 deletions kasa/interfaces/thermostat.py
Original file line number Diff line number Diff line change
@@ -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"]

Check warning on line 54 in kasa/interfaces/thermostat.py

View check run for this annotation

Codecov / codecov/patch

kasa/interfaces/thermostat.py#L54

Added line #L54 was not covered by tests

@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."""
1 change: 1 addition & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions kasa/smart/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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
Expand Down Expand Up @@ -61,5 +62,6 @@
"MotionSensor",
"TriggerLogs",
"FrostProtection",
"Thermostat",
"SmartLightEffect",
]
14 changes: 1 addition & 13 deletions kasa/smart/modules/temperaturecontrol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -56,7 +46,6 @@ def _initialize_features(self) -> None:
category=Feature.Category.Config,
)
)

self._add_feature(
Feature(
self._device,
Expand All @@ -69,7 +58,6 @@ def _initialize_features(self) -> None:
type=Feature.Type.Switch,
)
)

self._add_feature(
Feature(
self._device,
Expand Down
74 changes: 74 additions & 0 deletions kasa/smart/modules/thermostat.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 61 in kasa/smart/modules/thermostat.py

View check run for this annotation

Codecov / codecov/patch

kasa/smart/modules/thermostat.py#L61

Added line #L61 was not covered by tests

@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)
6 changes: 6 additions & 0 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
DeviceModule,
Firmware,
Light,
Thermostat,
Time,
)
from .smartmodule import SmartModule
Expand Down Expand Up @@ -361,6 +362,11 @@ async def _initialize_modules(self) -> None:
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."""
Expand Down
13 changes: 13 additions & 0 deletions tests/fakeprotocol_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,17 @@ def _edit_preset_rules(self, info, params):
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.

Expand Down Expand Up @@ -551,6 +562,8 @@ async def _send_request(self, request_dict: dict):
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":
Expand Down
41 changes: 40 additions & 1 deletion tests/test_common_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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,
Expand Down Expand Up @@ -57,6 +57,12 @@

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):
Expand Down Expand Up @@ -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]
Expand Down
Loading