Update tplink climate platform to use thermostat module (#136166)

This commit is contained in:
Steven B. 2025-01-25 09:38:06 +00:00 committed by GitHub
parent b25b97b6b6
commit 28951096a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 106 additions and 58 deletions

View File

@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, cast
from kasa import Device
from kasa import Device, Module
from kasa.smart.modules.temperaturecontrol import ThermostatState
from homeassistant.components.climate import (
@ -19,7 +19,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import PRECISION_TENTHS
from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -42,6 +42,7 @@ STATE_TO_ACTION = {
ThermostatState.Idle: HVACAction.IDLE,
ThermostatState.Heating: HVACAction.HEATING,
ThermostatState.Off: HVACAction.OFF,
ThermostatState.Calibrating: HVACAction.IDLE,
}
@ -62,7 +63,7 @@ class TPLinkClimateEntityDescription(
CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = (
TPLinkClimateEntityDescription(
key="climate",
exists_fn=lambda dev, _: dev.device_type is Device.Type.Thermostat,
exists_fn=lambda dev, _: Module.Thermostat in dev.modules,
),
)
@ -124,27 +125,42 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
) -> None:
"""Initialize the climate entity."""
super().__init__(device, coordinator, description, parent=parent)
self._state_feature = device.features["state"]
self._mode_feature = device.features["thermostat_mode"]
self._temp_feature = device.features["temperature"]
self._target_feature = device.features["target_temperature"]
self._thermostat_module = device.modules[Module.Thermostat]
self._attr_min_temp = self._target_feature.minimum_value
self._attr_max_temp = self._target_feature.maximum_value
self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)]
if target_feature := self._thermostat_module.get_feature("target_temperature"):
self._attr_min_temp = target_feature.minimum_value
self._attr_max_temp = target_feature.maximum_value
else:
_LOGGER.error(
"Unable to get min/max target temperature for %s, using defaults",
device.host,
)
if temperature_feature := self._thermostat_module.get_feature("temperature"):
self._attr_temperature_unit = UNIT_MAPPING[
cast(str, temperature_feature.unit)
]
else:
_LOGGER.error(
"Unable to get correct temperature unit for %s, defaulting to celsius",
device.host,
)
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
@async_refresh_after
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set target temperature."""
await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE]))
await self._thermostat_module.set_target_temperature(
float(kwargs[ATTR_TEMPERATURE])
)
@async_refresh_after
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode (heat/off)."""
if hvac_mode is HVACMode.HEAT:
await self._state_feature.set_value(True)
await self._thermostat_module.set_state(True)
elif hvac_mode is HVACMode.OFF:
await self._state_feature.set_value(False)
await self._thermostat_module.set_state(False)
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
@ -157,35 +173,33 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
@async_refresh_after
async def async_turn_on(self) -> None:
"""Turn heating on."""
await self._state_feature.set_value(True)
await self._thermostat_module.set_state(True)
@async_refresh_after
async def async_turn_off(self) -> None:
"""Turn heating off."""
await self._state_feature.set_value(False)
await self._thermostat_module.set_state(False)
@callback
def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
self._attr_current_temperature = cast(float | None, self._temp_feature.value)
self._attr_target_temperature = cast(float | None, self._target_feature.value)
self._attr_current_temperature = self._thermostat_module.temperature
self._attr_target_temperature = self._thermostat_module.target_temperature
self._attr_hvac_mode = (
HVACMode.HEAT if self._state_feature.value else HVACMode.OFF
HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
)
if (
self._mode_feature.value not in STATE_TO_ACTION
self._thermostat_module.mode not in STATE_TO_ACTION
and self._attr_hvac_action is not HVACAction.OFF
):
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
self._mode_feature.value,
self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
return True
self._attr_hvac_action = STATE_TO_ACTION[
cast(ThermostatState, self._mode_feature.value)
]
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
return True

View File

@ -145,9 +145,6 @@
"temperature_offset": {
"default": "mdi:contrast"
},
"target_temperature": {
"default": "mdi:thermometer"
},
"pan_step": {
"default": "mdi:unfold-more-vertical"
},

View File

@ -6,8 +6,16 @@ from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from kasa import BaseProtocol, Device, DeviceType, Feature, KasaException, Module
from kasa.interfaces import Fan, Light, LightEffect, LightState
from kasa import (
BaseProtocol,
Device,
DeviceType,
Feature,
KasaException,
Module,
ThermostatState,
)
from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat
from kasa.smart.modules.alarm import Alarm
from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera
from syrupy import SnapshotAssertion
@ -361,6 +369,18 @@ def _mocked_camera_module(device):
return camera
def _mocked_thermostat_module(device):
therm = MagicMock(auto_spec=Thermostat, name="Mocked thermostat")
therm.state = True
therm.temperature = 20.2
therm.target_temperature = 22.2
therm.mode = ThermostatState.Heating
therm.set_state = AsyncMock()
therm.set_target_temperature = AsyncMock()
return therm
def _mocked_strip_children(features=None, alias=None) -> list[Device]:
plug0 = _mocked_device(
alias="Plug0" if alias is None else alias,
@ -429,6 +449,7 @@ MODULE_TO_MOCK_GEN = {
Module.Fan: _mocked_fan_module,
Module.Alarm: _mocked_alarm_module,
Module.Camera: _mocked_camera_module,
Module.Thermostat: _mocked_thermostat_module,
}

View File

@ -2,7 +2,7 @@
from datetime import timedelta
from kasa import Device, Feature
from kasa import Device, Feature, Module
from kasa.smart.modules.temperaturecontrol import ThermostatState
import pytest
from syrupy.assertion import SnapshotAssertion
@ -45,31 +45,24 @@ async def mocked_hub(hass: HomeAssistant) -> Device:
features = [
_mocked_feature(
"temperature", value=20.2, category=Feature.Category.Primary, unit="celsius"
),
_mocked_feature(
"target_temperature",
value=22.2,
"temperature",
type_=Feature.Type.Number,
category=Feature.Category.Primary,
unit="celsius",
),
_mocked_feature(
"state",
value=True,
type_=Feature.Type.Switch,
category=Feature.Category.Primary,
),
_mocked_feature(
"thermostat_mode",
value=ThermostatState.Heating,
type_=Feature.Type.Choice,
"target_temperature",
type_=Feature.Type.Number,
category=Feature.Category.Primary,
unit="celsius",
),
]
thermostat = _mocked_device(
alias="thermostat", features=features, device_type=Device.Type.Thermostat
alias="thermostat",
features=features,
modules=[Module.Thermostat],
device_type=Device.Type.Thermostat,
)
return _mocked_device(
@ -121,7 +114,9 @@ async def test_set_temperature(
) -> None:
"""Test that set_temperature service calls the setter."""
mocked_thermostat = mocked_hub.children[0]
mocked_thermostat.features["target_temperature"].minimum_value = 0
therm_module = mocked_thermostat.modules.get(Module.Thermostat)
assert therm_module
await setup_platform_for_device(
hass, mock_config_entry, Platform.CLIMATE, mocked_hub
@ -133,8 +128,8 @@ async def test_set_temperature(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10},
blocking=True,
)
target_temp_feature = mocked_thermostat.features["target_temperature"]
target_temp_feature.set_value.assert_called_with(10)
therm_module.set_target_temperature.assert_called_with(10)
async def test_set_hvac_mode(
@ -146,8 +141,8 @@ async def test_set_hvac_mode(
)
mocked_thermostat = mocked_hub.children[0]
mocked_state = mocked_thermostat.features["state"]
assert mocked_state is not None
therm_module = mocked_thermostat.modules.get(Module.Thermostat)
assert therm_module
await hass.services.async_call(
CLIMATE_DOMAIN,
@ -156,7 +151,7 @@ async def test_set_hvac_mode(
blocking=True,
)
mocked_state.set_value.assert_called_with(False)
therm_module.set_state.assert_called_with(False)
await hass.services.async_call(
CLIMATE_DOMAIN,
@ -164,7 +159,7 @@ async def test_set_hvac_mode(
{ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
mocked_state.set_value.assert_called_with(True)
therm_module.set_state.assert_called_with(True)
msg = "Tried to set unsupported mode: dry"
with pytest.raises(ServiceValidationError, match=msg):
@ -185,7 +180,8 @@ async def test_turn_on_and_off(
)
mocked_thermostat = mocked_hub.children[0]
mocked_state = mocked_thermostat.features["state"]
therm_module = mocked_thermostat.modules.get(Module.Thermostat)
assert therm_module
await hass.services.async_call(
CLIMATE_DOMAIN,
@ -194,7 +190,7 @@ async def test_turn_on_and_off(
blocking=True,
)
mocked_state.set_value.assert_called_with(False)
therm_module.set_state.assert_called_with(False)
await hass.services.async_call(
CLIMATE_DOMAIN,
@ -203,7 +199,7 @@ async def test_turn_on_and_off(
blocking=True,
)
mocked_state.set_value.assert_called_with(True)
therm_module.set_state.assert_called_with(True)
async def test_unknown_mode(
@ -218,11 +214,31 @@ async def test_unknown_mode(
)
mocked_thermostat = mocked_hub.children[0]
mocked_state = mocked_thermostat.features["thermostat_mode"]
mocked_state.value = ThermostatState.Unknown
therm_module = mocked_thermostat.modules.get(Module.Thermostat)
assert therm_module
therm_module.mode = ThermostatState.Unknown
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF
assert "Unknown thermostat state, defaulting to OFF" in caplog.text
async def test_missing_feature_attributes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mocked_hub: Device,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a module missing the min/max and unit feature logs an error."""
mocked_thermostat = mocked_hub.children[0]
mocked_thermostat.features.pop("target_temperature")
mocked_thermostat.features.pop("temperature")
await setup_platform_for_device(
hass, mock_config_entry, Platform.CLIMATE, mocked_hub
)
assert "Unable to get min/max target temperature" in caplog.text
assert "Unable to get correct temperature unit" in caplog.text

View File

@ -1007,8 +1007,8 @@ async def test_automatic_feature_device_addition_and_removal(
),
pytest.param(
"climate",
[],
["state", "thermostat_mode", "temperature", "target_temperature"],
[Module.Thermostat],
["temperature", "target_temperature"],
None,
DeviceType.Thermostat,
id="climate",