mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +00:00
Update tplink climate platform to use thermostat module (#136166)
This commit is contained in:
parent
b25b97b6b6
commit
28951096a8
@ -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
|
||||
|
@ -145,9 +145,6 @@
|
||||
"temperature_offset": {
|
||||
"default": "mdi:contrast"
|
||||
},
|
||||
"target_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"pan_step": {
|
||||
"default": "mdi:unfold-more-vertical"
|
||||
},
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user