8000 Improve temperature controls (#872) · python-kasa/python-kasa@9dcd8ec · GitHub
[go: up one dir, main page]

Skip to content

Commit 9dcd8ec

Browse files
authored
Improve temperature controls (#872)
This improves the temperature control features to allow implementing climate platform support for homeassistant. Also adds frostprotection module, which is also used to turn the thermostat on and off.
1 parent 28d4109 commit 9dcd8ec

File tree

6 files changed

+252
-3
lines changed

6 files changed

+252
-3
lines changed

kasa/smart/modules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .energymodule import EnergyModule
1313
from .fanmodule import FanModule
1414
from .firmware import Firmware
15+
from .frostprotection import FrostProtectionModule
1516
from .humidity import HumiditySensor
1617
from .ledmodule import LedModule
1718
from .lighttransitionmodule import LightTransitionModule
@@ -42,4 +43,5 @@
4243
"ColorTemperatureModule",
4344
"ColorModule",
4445
"WaterleakSensor",
46+
"FrostProtectionModule",
4547
]

kasa/smart/modules/frostprotection.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Frost protection module."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from ...feature import Feature
8+
from ..smartmodule import SmartModule
9+
10+
# TODO: this may not be necessary with __future__.annotations
11+
if TYPE_CHECKING:
12+
from ..smartdevice import SmartDevice
13+
14+
15+
class FrostProtectionModule(SmartModule):
16+
"""Implementation for frost protection module.
17+
18+
This basically turns the thermostat on and off.
19+
"""
20+
21+
REQUIRED_COMPONENT = "frost_protection"
22+
# TODO: the information required for current features do not require this query
23+
QUERY_GETTER_NAME = "get_frost_protection"
24+
25+
def __init__(self, device: SmartDevice, module: str):
26+
super().__init__(device, module)
27+
self._add_feature(
28+
Feature(
29+
device,
30+
name="Frost protection enabled",
31+
container=self,
32+
attribute_getter="enabled",
33+
attribute_setter="set_enabled",
34+
type=Feature.Type.Switch,
35+
)
36+
)
37+
38+
@property
39+
def enabled(self) -> bool:
40+
"""Return True if frost protection is on."""
41+
return self._device.sys_info["frost_protection_on"]
42+
43+
async def set_enabled(self, enable: bool):
44+
"""Enable/disable frost protection."""
45+
return await self.call(
46+
"set_device_info",
47+
{"frost_protection_on": enable},
48+
)
49+
50+
@property
51+
def minimum_temperature(self) -> int:
52+
"""Return frost protection minimum temperature."""
53+
return self.data["min_temp"]
54+
55+
@property
56+
def temperature_unit(self) -> str:
57+
"""Return frost protection temperature unit."""
58+
return self.data["temp_unit"]

kasa/smart/modules/humidity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str):
2626
container=self,
2727
attribute_getter="humidity",
2828
icon="mdi:water-percent",
29+
unit="%",
2930
)
3031
)
3132
self._add_feature(

kasa/smart/modules/temperature.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str):
2626
container=self,
2727
attribute_getter="temperature",
2828
icon="mdi:thermometer",
29+
category=Feature.Category.Primary,
2930
)
3031
)
3132
if "current_temp_exception" in device.sys_info:

kasa/smart/modules/temperaturecontrol.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
from enum import Enum
57
from typing import TYPE_CHECKING
68

79
from ...feature import Feature
@@ -11,6 +13,19 @@
1113
from ..smartdevice import SmartDevice
1214

1315

16+
_LOGGER = logging.getLogger(__name__)
17+
18+
19+
class ThermostatState(Enum):
20+
"""Thermostat state."""
21+
22+
Heating = "heating"
23+
Calibrating = "progress_calibration"
24+
Idle = "idle"
25+
Off = "off"
26+
Unknown = "unknown"
27+
28+
1429
class TemperatureControl(SmartModule):
1530
"""Implementation of temperature module."""
1631

@@ -25,8 +40,10 @@ def __init__(self, device: SmartDevice, module: str):
2540
container=self,
2641
attribute_getter="target_temperature",
2742
attribute_setter="set_target_temperature",
43+
range_getter="allowed_temperature_range",
2844
icon="mdi:thermometer",
2945
type=Feature.Type.Number,
46+
category=Feature.Category.Primary,
3047
)
3148
)
3249
# TODO: this might belong into its own module, temperature_correction?
@@ -40,6 +57,29 @@ def __init__(self, device: SmartDevice, module: str):
4057
minimum_value=-10,
4158
maximum_value=10,
4259
type=Feature.Type.Number,
60+
category=Feature.Category.Config,
61+
)
62+
)
63+
64+
self._add_feature(
65+
Feature(
66+
device,
67+
"State",
68+
container=self,
69+
attribute_getter="state",
70+
attribute_setter="set_state",
71+
category=Feature.Category.Primary,
72+
type=Feature.Type.Switch,
73+
)
74+
)
75+
76+
self._add_feature(
77+
Feature(
78+
device,
79+
"Mode",
80+
container=self,
81+
attribute_getter="mode",
82+
category=Feature.Category.Primary,
4383
)
4484
)
4585

@@ -48,6 +88,45 @@ def query(self) -> dict:
4888
# Target temperature is contained in the main device info response.
4989
return {}
5090

91+
@property
92+
def state(self) -> bool:
93+
"""Return thermostat state."""
94+
return self._device.sys_info["frost_protection_on"] is False
95+
96+
async def set_state(self, enabled: bool):
97+
"""Set thermostat state."""
98+
return await self.call("set_device_info", {"frost_protection_on": not enabled})
99+
100+
@property
101+
def mode(self) -> ThermostatState:
102+
"""Return thermostat state."""
103+
# If frost protection is enabled, the thermostat is off.
104+
if self._device.sys_info.get("frost_protection_on", False):
105+
return ThermostatState.Off
106+
107+
states = self._device.sys_info["trv_states"]
108+
109+
# If the states is empty, the device is idling
110+
if not states:
111+
return ThermostatState.Idle
112+
113+
if len(states) > 1:
114+
_LOGGER.warning(
115+
"Got multiple states (%s), using the first one: %s", states, states[0]
116+
)
117+
118+
state = states[0]
119+
try:
120+
return ThermostatState(state)
121+
except: # noqa: E722
122+
_LOGGER.warning("Got unknown state: %s", state)
123+
return ThermostatState.Unknown
124+
125+
@property
126+
def allowed_temperature_range(self) -> tuple[int, int]:
127+
"""Return allowed temperature range."""
128+
return self.minimum_target_temperature, self.maximum_target_temperature
129+
51130
@property
52131
def minimum_target_temperature(self) -> int:
53132
"""Minimum available target temperature."""
@@ -74,7 +153,12 @@ async def set_target_temperature(self, target: float):
74153
f"[{self.minimum_target_temperature},{self.maximum_target_temperature}]"
75154
)
76155

77-
return await self.call("set_device_info", {"target_temp": target})
156+
payload = {"target_temp": target}
157+
# If the device has frost protection, we set it off to enable heating
158+
if "frost_protection_on" in self._device.sys_info:
159+
payload["frost_protection_on"] = False
160+
161+
return await self.call("set_device_info", payload)
78162

79163
@property
80164
def temperature_offset(self) -> int:

kasa/tests/smart/modules/test_temperaturecontrol.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import logging
2+
13
import pytest
24

3-
from kasa.smart.modules import TemperatureSensor
5+
from kasa.smart.modules import TemperatureControl
6+
from kasa.smart.modules.temperaturecontrol import ThermostatState
47
from kasa.tests.device_fixtures import parametrize, thermostats_smart
58

69
temperature = parametrize(
@@ -20,7 +23,7 @@
2023
)
2124
async def test_temperature_control_features(dev, feature, type):
2225
"""Test that features are registered and work as expected."""
23-
temp_module: TemperatureSensor = dev.modules["TemperatureControl"]
26+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
2427

2528
prop = getattr(temp_module, feature)
2629
assert isinstance(prop, type)
@@ -32,3 +35,103 @@ async def test_temperature_control_features(dev, feature, type):
3235
await feat.set_value(10)
3336
await dev.update()
3437
assert feat.value == 10
38+
39+
40+
@thermostats_smart
41+
async def test_set_temperature_turns_heating_on(dev):
42+
"""Test that set_temperature turns heating on."""
43+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
44+
45+
await temp_module.set_state(False)
46+
await dev.update()
47+
assert temp_module.state is False
48+
assert temp_module.mode is ThermostatState.Off
49+
50+
await temp_module.set_target_temperature(10)
51+
await dev.update()
52+
assert temp_module.state is True
53+
assert temp_module.mode is ThermostatState.Heating
54+
assert temp_module.target_temperature == 10
55+
56+
57+
@thermostats_smart
58+
async def test_set_temperature_invalid_values(dev):
59+
"""Test that out-of-bounds temperature values raise errors."""
60+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
61+
62+
with pytest.raises(ValueError):
63+
await temp_module.set_target_temperature(-1)
64+
65+
with pytest.raises(ValueError):
66+
await temp_module.set_target_temperature(100)
67+
68+
69+
@thermostats_smart
70+
async def test_temperature_offset(dev):
71+
"""Test the temperature offset API."""
72+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
73+
with pytest.raises(ValueError):
74+
await temp_module.set_temperature_offset(100)
75+
76+
with pytest.raises(ValueError):
77+
await temp_module.set_temperature_offset(-100)
78+
79+
await temp_module.set_temperature_offset(5)
80+
await dev.update()
81+
assert temp_module.temperature_offset == 5
82+
83+
84+
@thermostats_smart
85+
@pytest.mark.parametrize(
86+
"mode, states, frost_protection",
87+
[
88+
pytest.param(ThermostatState.Idle, [], False, id="idle has empty"),
89+
pytest.param(
90+
ThermostatState.Off,
91+
["anything"],
92+
True,
93+
id="any state with frost_protection on means off",
94+
),
95+
pytest.param(
96+
ThermostatState.Heating,
97+
[ThermostatState.Heating],
98+
False,
99+
id="heating is heating",
100+
),
101+
pytest.param(ThermostatState.Unknown, ["invalid"], False, id="unknown state"),
102+
],
103+
)
104+
async def test_thermostat_mode(dev, mode, states, frost_protection):
105+
"""Test different thermostat modes."""
106+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
107+
108+
temp_module.data["frost_protection_on"] = frost_protection
109+
temp_module.data["trv_states"] = states
110+
111+
assert temp_module.state is not frost_protection
112< C552 span class="diff-text-marker">+
assert temp_module.mode is mode
113+
114+
115+
@thermostats_smart
116+
@pytest.mark.parametrize(
117+
"mode, states, msg",
118+
[
119+
pytest.param(
120+
ThermostatState.Heating,
121+
["heating", "something else"],
122+
"Got multiple states",
123+
id="multiple states",
124+
),
125+
pytest.param(
126+
ThermostatState.Unknown, ["foobar"], "Got unknown state", id="unknown state"
127+
),
128+
],
129+
)
130+
async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog):
131+
"""Test thermostat modes that should log a warning."""
132+
temp_module: TemperatureControl = dev.modules["TemperatureControl"]
133+
caplog.set_level(logging.WARNING)
134+
135+
temp_module.data["trv_states"] = states
136+
assert temp_module.mode is mode
137+
assert msg in caplog.text

0 commit comments

Comments
 (0)
0