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

View File

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

View File

@ -6,8 +6,16 @@ from datetime import datetime
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from kasa import BaseProtocol, Device, DeviceType, Feature, KasaException, Module from kasa import (
from kasa.interfaces import Fan, Light, LightEffect, LightState 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.smart.modules.alarm import Alarm
from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -361,6 +369,18 @@ def _mocked_camera_module(device):
return camera 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]: def _mocked_strip_children(features=None, alias=None) -> list[Device]:
plug0 = _mocked_device( plug0 = _mocked_device(
alias="Plug0" if alias is None else alias, alias="Plug0" if alias is None else alias,
@ -429,6 +449,7 @@ MODULE_TO_MOCK_GEN = {
Module.Fan: _mocked_fan_module, Module.Fan: _mocked_fan_module,
Module.Alarm: _mocked_alarm_module, Module.Alarm: _mocked_alarm_module,
Module.Camera: _mocked_camera_module, Module.Camera: _mocked_camera_module,
Module.Thermostat: _mocked_thermostat_module,
} }

View File

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