diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index d4800d9e951..7204c2a7665 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -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 diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index aedbccfbd51..e00e8f69467 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -145,9 +145,6 @@ "temperature_offset": { "default": "mdi:contrast" }, - "target_temperature": { - "default": "mdi:thermometer" - }, "pan_step": { "default": "mdi:unfold-more-vertical" }, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 81ee679a251..a056555f4c0 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -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, } diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index b1c8abd3a9b..adcca24886b 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -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 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 01f422636b2..ffcadc79faf 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -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",