8000 Add common Thermostat module (#977) · MAXIGAMESSUPPER/python-kasa@3dfada7 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3dfada7

Browse files
authored
Add common Thermostat module (python-kasa#977)
1 parent cb4e283 commit 3dfada7

File tree

10 files changed

+208
-14
lines changed

10 files changed

+208
-14
lines changed

kasa/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
)
3737
from kasa.feature import Feature
3838
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
39+
from kasa.interfaces.thermostat import Thermostat, ThermostatState
3940
from kasa.module import Module
4041
from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol
4142
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
@@ -72,6 +73,8 @@
7273
"DeviceConnectionParameters",
7374
"DeviceEncryptionType",
7475
"DeviceFamily",
76+
"ThermostatState",
77+
"Thermostat",
7578
]
7679

7780
from . import iot

kasa/interfaces/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .light import Light, LightState
77
from .lighteffect import LightEffect
88
from .lightpreset import LightPreset
9+
from .thermostat import Thermostat, ThermostatState
910
from .time import Time
1011

1112
__all__ = [
@@ -16,5 +17,7 @@
1617
"LightEffect",
1718
"LightState",
1819
"LightPreset",
20+
"Thermostat",
21+
"ThermostatState",
1922
"Time",
2023
]

kasa/interfaces/thermostat.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Interact with a TPLink Thermostat."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from enum import Enum
7+
from typing import Annotated, Literal
8+
9+
from ..module import FeatureAttribute, Module
10+
11+
12+
class ThermostatState(Enum):
13+
"""Thermostat state."""
14+
15+
Heating = "heating"
16+
Calibrating = "progress_calibration"
17+
Idle = "idle"
18+
Off = "off"
19+
Unknown = "unknown"
20+
21+
22+
class Thermostat(Module, ABC):
23+
"""Base class for TP-Link Thermostat."""
24+
25+
@property
26+
@abstractmethod
27+
def state(self) -> bool:
28+
"""Return thermostat state."""
29+
30+
@abstractmethod
31+
async def set_state(self, enabled: bool) -> dict:
32+
"""Set thermostat state."""
33+
34+
@property
35+
@abstractmethod
36+
def mode(self) -> ThermostatState:
37+
"""Return thermostat state."""
38+
39+
@property
40+
@abstractmethod
41+
def target_temperature(self) -> Annotated[float, FeatureAttribute()]:
42+
"""Return target temperature."""
43+
44+
@abstractmethod
F438 45+
async def set_target_temperature(
46+
self, target: float
47+
) -> Annotated[dict, FeatureAttribute()]:
48+
"""Set target temperature."""
49+
50+
@property
51+
@abstractmethod
52+
def temperature(self) -> Annotated[float, FeatureAttribute()]:
53+
"""Return current humidity in percentage."""
54+
return self._device.sys_info["current_temp"]
55+
56+
@property
57+
@abstractmethod
58+
def temperature_unit(self) -> Literal["celsius", "fahrenheit"]:
59+
"""Return current temperature unit."""
60+
61+
@abstractmethod
62+
async def set_temperature_unit(
63+
self, unit: Literal["celsius", "fahrenheit"]
64+
) -> dict:
65+
"""Set the device temperature unit."""

kasa/module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class Module(ABC):
9696
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
9797
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
9898
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
99+
Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat")
99100
Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time")
100101

101102
# IOT only Modules

kasa/smart/modules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .reportmode import ReportMode
2828
from .temperaturecontrol import TemperatureControl
2929
from .temperaturesensor import TemperatureSensor
30+
from .thermostat import Th 10000 ermostat
3031
from .time import Time
3132
from .triggerlogs import TriggerLogs
3233
from .waterleaksensor import WaterleakSensor
@@ -61,5 +62,6 @@
6162
"MotionSensor",
6263
"TriggerLogs",
6364
"FrostProtection",
65+
"Thermostat",
6466
"SmartLightEffect",
6567
]

kasa/smart/modules/temperaturecontrol.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,14 @@
33
from __future__ import annotations
44

55
import logging
6-
from enum import Enum
76

87
from ...feature import Feature
8+
from ...interfaces.thermostat import ThermostatState
99
from ..smartmodule import SmartModule
1010

1111
_LOGGER = logging.getLogger(__name__)
1212

1313

14-
class ThermostatState(Enum):
15-
"""Thermostat state."""
16-
17-
Heating = "heating"
18-
Calibrating = "progress_calibration"
19-
Idle = "idle"
20-
Off = "off"
21-
Unknown = "unknown"
22-
23-
2414
class TemperatureControl(SmartModule):
2515
"""Implementation of temperature module."""
2616

@@ -56,7 +46,6 @@ def _initialize_features(self) -> None:
5646
category=Feature.Category.Config,
5747
)
5848
)
59-
6049
self._add_feature(
6150
Feature(
6251
self._device,
@@ -69,7 +58,6 @@ def _initialize_features(self) -> None:
6958
type=Feature.Type.Switch,
7059
)
7160
)
72-
7361
self._add_feature(
7462
Feature(
7563
self._device,

kasa/smart/modules/thermostat.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Module for a Thermostat."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated, Literal
6+
7+
from ...feature import Feature
8+
from ...interfaces.thermostat import Thermostat as ThermostatInterface
9+
from ...interfaces.thermostat import ThermostatState
10+
from ...module import FeatureAttribute, Module
11+
from ..smartmodule import SmartModule
12+
13+
14+
class Thermostat(SmartModule, ThermostatInterface):
15+
"""Implementation of a Thermostat."""
16+
17+
@property
18+
def _all_features(self) -> dict[str, Feature]:
19+
"""Get the features for this module and any sub modules."""
20+
ret: dict[str, Feature] = {}
21+
if temp_control := self._device.modules.get(Module.TemperatureControl):
22+
ret.update(**temp_control._module_features)
23+
if temp_sensor := self._device.modules.get(Module.TemperatureSensor):
24+
ret.update(**temp_sensor._module_features)
25+
return ret
26+
27+
def query(self) -> dict:
28+
"""Query to execute during the update cycle."""
29+
return {}
30+
31+
@property
32+
def state(self) -> bool:
33+
"""Return thermostat state."""
34+
return self._device.modules[Module.TemperatureControl].state
35+
36+
async def set_state(self, enabled: bool) -> dict:
37+
"""Set thermostat state."""
38+
return await self._device.modules[Module.TemperatureControl].set_state(enabled)
39+
40+
@property
41+
def mode(self) -> ThermostatState:
42+
"""Return thermostat state."""
43+
return self._device.modules[Module.TemperatureControl].mode
44+
45+
@property
46+
def target_temperature(self) -> Annotated[float, FeatureAttribute()]:
47+
"""Return target temperature."""
48+
return self._device.modules[Module.TemperatureControl].target_temperature
49+
50+
async def set_target_temperature(
51+
self, target: float
52+
) -> Annotated[dict, FeatureAttribute()]:
53+
"""Set target temperature."""
54+
return await self._device.modules[
55+
Module.TemperatureControl
56+
].set_target_temperature(target)
57+
58+
@property
59+
def temperature(self) -> Annotated[float, FeatureAttribute()]:
60+
"""Return current humidity in percentage."""
61+
return self._device.modules[Module.TemperatureSensor].temperature
62+
63+
@property
64+
def temperature_unit(self) -> Literal["celsius", "fahrenheit"]:
65+
"""Return current temperature unit."""
66+
return self._device.modules[Module.TemperatureSensor].temperature_unit
67+
68+
async def set_temperature_unit(
69+
self, unit: Literal["celsius", "fahrenheit"]
70+
) -> dict:
71+
"""Set the device temperature unit."""
72+
return await self._device.modules[
73+
Module.TemperatureSensor
74+
].set_temperature_unit(unit)

kasa/smart/smartdevice.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DeviceModule,
2525
Firmware,
2626
Light,
27+
Thermostat,
2728
Time,
2829
)
2930
from .smartmodule import SmartModule
@@ -361,6 +362,11 @@ async def _initialize_modules(self) -> None:
361362
or Module.ColorTemperature in self._modules
362363
):
363364
self._modules[Light.__name__] = Light(self, "light")
365+
if (
366+
Module.TemperatureControl in self._modules
367+
and Module.TemperatureSensor in self._modules
368+
):
369+
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat")
364370

365371
async def _initialize_features(self) -> None:
366372
"""Initialize device features."""

tests/fakeprotocol_smart.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,17 @@ def _edit_preset_rules(self, info, params):
449449
info["get_preset_rules"]["states"][params["index"]] = params["state"]
450450
return {"error_code": 0}
451451

452+
def _set_temperature_unit(self, info, params):
453+
"""Set or remove values as per the device behaviour."""
454+
unit = params["temp_unit"]
455+
if unit not in {"celsius", "fahrenheit"}:
456+
raise ValueError(f"Invalid value for temperature unit {unit}")
457+
if "temp_unit" not in info["get_device_info"]:
458+
return {"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR}
459+
else:
460+
info["get_device_info"]["temp_unit"] = unit
461+
return {"error_code": 0}
462+
452463
def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict:
453464
"""Update a single key in the main system info.
454465
@@ -551,6 +562,8 @@ async def _send_request(self, request_dict: dict):
551562
return self._set_preset_rules(info, params)
552563
elif method == "edit_preset_rules":
553564
return self._edit_preset_rules(info, params)
565+
elif method == "set_temperature_unit":
566+
return self._set_temperature_unit(info, params)
554567
elif method == "set_on_off_gradually_info":
555568
return self._set_on_off_gradually_info(info, params)
556569
elif method == "set_child_protection":

tests/test_common_modules.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55
from pytest_mock import MockerFixture
66

7-
from kasa import Device, LightState, Module
7+
from kasa import Device, LightState, Module, ThermostatState
88

99
from .device_fixtures import (
1010
bulb_iot,
@@ -57,6 +57,12 @@
5757

5858
light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
5959

60+
temp_control_smart = parametrize(
61+
"has temp control smart",
62+
component_filter="temp_control",
63+
protocol_filter={"SMART.CHILD"},
64+
)
65+
6066

6167
@led
6268
async def test_led_module(dev: Device, mocker: MockerFixture):
@@ -325,6 +331,39 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture):
325331
assert new_preset_state.color_temp == new_preset.color_temp
326332

327333

334+
@temp_control_smart
335+
async def test_thermostat(dev: Device, mocker: MockerFixture):
336+
"""Test saving a new preset value."""
337+
therm_mod = next(get_parent_and_child_modules(dev, Module.Thermostat))
338+
assert therm_mod
339+
340+
await therm_mod.set_state(False)
341+
await dev.update()
342+
assert therm_mod.state is False
343+
assert therm_mod.mode is ThermostatState.Off
344+
345+
await therm_mod.set_target_temperature(10)
346+
await dev.update()
347+
assert therm_mod.state is True
348+
assert therm_mod.mode is ThermostatState.Heating
349+
assert therm_mod.target_temperature == 10
350+
351+
target_temperature_feature = therm_mod.get_feature(therm_mod.set_target_temperature)
352+
temp_control = dev.modules.get(Module.TemperatureControl)
353+
assert temp_control
354+
allowed_range = temp_control.allowed_temperature_range
355+
assert target_temperature_feature.minimum_value == allowed_range[0]
356+
assert target_temperature_feature.maximum_value == allowed_range[1]
357+
358+
await therm_mod.set_temperature_unit("celsius")
359+
await dev.update()
360+
assert therm_mod.temperature_unit == "celsius"
361+
362+
await therm_mod.set_temperature_unit("fahrenheit")
363+
await dev.update()
364+
assert therm_mod.temperature_unit == "fahrenheit"
365+
366+
328367
async def test_set_time(dev: Device):
329368
"""Test setting the device time."""
330369
time_mod = dev.modules[Module.Time]

0 commit comments

Comments
 (0)
0