Watts: add HVAC action + preset mode (#169546)

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
theobld-ww
2026-05-11 13:27:01 +02:00
committed by GitHub
co-authored by Copilot Joostlek
parent 261ca2dd9a
commit 7ba7700d5e
12 changed files with 272 additions and 31 deletions
+58 -4
View File
@@ -3,11 +3,12 @@
import logging
from typing import Any
from visionpluspython.models import ThermostatDevice
from visionpluspython.models import ThermostatDevice, ThermostatMode
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
@@ -17,7 +18,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WattsVisionConfigEntry
from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC
from .const import (
DOMAIN,
HVAC_ACTION_TO_HA,
HVAC_MODE_TO_THERMOSTAT,
PRESET_MODE_TO_THERMOSTAT,
PRESET_MODES,
THERMOSTAT_MODE_TO_HVAC,
THERMOSTAT_MODE_TO_PRESET,
)
from .coordinator import WattsVisionDeviceCoordinator
from .entity import WattsVisionEntity
@@ -26,6 +35,10 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
def _parse_thermostat_mode(mode: str) -> ThermostatMode:
return ThermostatMode[mode.upper()]
async def async_setup_entry(
hass: HomeAssistant,
entry: WattsVisionConfigEntry,
@@ -79,9 +92,13 @@ async def async_setup_entry(
class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
"""Representation of a Watts Vision heater as a climate entity."""
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]
_attr_preset_modes = PRESET_MODES
_attr_name = None
_attr_translation_key = "thermostat"
def __init__(
self,
@@ -112,7 +129,44 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac mode."""
return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode)
return THERMOSTAT_MODE_TO_HVAC.get(
_parse_thermostat_mode(self.device.thermostat_mode)
)
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
return HVAC_ACTION_TO_HA.get(self.device.hvac_action)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return THERMOSTAT_MODE_TO_PRESET.get(
_parse_thermostat_mode(self.device.thermostat_mode)
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
mode = PRESET_MODE_TO_THERMOSTAT[preset_mode]
try:
await self.coordinator.client.set_thermostat_mode(self.device_id, mode)
except (ValueError, RuntimeError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_preset_mode_error",
) from err
_LOGGER.debug(
"Successfully set preset mode to %s (ThermostatMode.%s) for %s",
preset_mode,
mode.name,
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
+40 -10
View File
@@ -2,7 +2,12 @@
from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode
from homeassistant.components.climate import HVACMode
from homeassistant.components.climate import (
PRESET_COMFORT,
PRESET_ECO,
HVACAction,
HVACMode,
)
DOMAIN = "watts"
@@ -20,20 +25,45 @@ UPDATE_INTERVAL_SECONDS = 30
FAST_POLLING_INTERVAL_SECONDS = 5
DISCOVERY_INTERVAL_MINUTES = 15
# Mapping from Watts Vision + modes to Home Assistant HVAC modes
THERMOSTAT_MODE_TO_HVAC = {
"Program": HVACMode.AUTO,
"Eco": HVACMode.HEAT,
"Comfort": HVACMode.HEAT,
"Off": HVACMode.OFF,
# Mapping from Watts Vision+ modes to Home Assistant HVAC modes
THERMOSTAT_MODE_TO_HVAC: dict[ThermostatMode, HVACMode] = {
ThermostatMode.PROGRAM: HVACMode.AUTO,
ThermostatMode.ECO: HVACMode.HEAT,
ThermostatMode.COMFORT: HVACMode.HEAT,
ThermostatMode.DEFROST: HVACMode.HEAT,
ThermostatMode.TIMER: HVACMode.HEAT,
ThermostatMode.OFF: HVACMode.OFF,
}
# Mapping from Home Assistant HVAC modes to Watts Vision + modes
HVAC_MODE_TO_THERMOSTAT = {
# Mapping from Home Assistant HVAC modes to Watts Vision+ modes
HVAC_MODE_TO_THERMOSTAT: dict[HVACMode, ThermostatMode] = {
HVACMode.HEAT: ThermostatMode.COMFORT,
HVACMode.OFF: ThermostatMode.OFF,
HVACMode.AUTO: ThermostatMode.PROGRAM,
}
# Preset modes available on all Watts Vision+ thermostats.
PRESET_MODES: list[str] = [PRESET_COMFORT, PRESET_ECO, "defrost", "timer"]
# Mapping from Watts Vision+ mode to HA preset mode string
THERMOSTAT_MODE_TO_PRESET: dict[ThermostatMode, str] = {
ThermostatMode.COMFORT: PRESET_COMFORT,
ThermostatMode.ECO: PRESET_ECO,
ThermostatMode.DEFROST: "defrost",
ThermostatMode.TIMER: "timer",
}
# Mapping from HA preset mode string to Watts Vision+ ThermostatMode
PRESET_MODE_TO_THERMOSTAT: dict[str, ThermostatMode] = {
v: k for k, v in THERMOSTAT_MODE_TO_PRESET.items()
}
# Mapping from Watts Vision+ HVAC actions to Home Assistant HVACAction
HVAC_ACTION_TO_HA: dict[str, HVACAction] = {
"Heating": HVACAction.HEATING,
"Cooling": HVACAction.COOLING,
"Idle": HVACAction.IDLE,
"Off": HVACAction.OFF,
}
SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice)
+18
View File
@@ -0,0 +1,18 @@
{
"entity": {
"climate": {
"thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"comfort": "mdi:weather-sunny",
"defrost": "mdi:snowflake",
"eco": "mdi:moon-waning-crescent",
"timer": "mdi:timer"
}
}
}
}
}
}
}
@@ -53,13 +53,9 @@ rules:
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations:
status: exempt
comment: No entity required translations.
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: Thermostat entities use standard HA Climate entity.
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
@@ -30,6 +30,20 @@
}
}
},
"entity": {
"climate": {
"thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"defrost": "Defrost",
"timer": "Timer"
}
}
}
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed"
@@ -58,6 +72,9 @@
"set_hvac_mode_error": {
"message": "An error occurred while setting the HVAC mode"
},
"set_preset_mode_error": {
"message": "An error occurred while setting the preset mode"
},
"set_switch_state_error": {
"message": "An error occurred while setting the switch state"
},
+1 -3
View File
@@ -83,9 +83,7 @@ def mock_watts_client() -> Generator[AsyncMock]:
switch_detail_data["deviceId"]: switch_detail,
}
async def get_device_side_effect(
device_id: str, refresh: bool = False
) -> Device:
async def get_device_side_effect(device_id: str) -> Device:
"""Return the appropriate device based on device_id."""
return device_details.get(device_id, device_detail)
@@ -8,6 +8,7 @@
"currentTemperature": 21.0,
"setpoint": 23.5,
"thermostatMode": "Comfort",
"hvacAction": "Heating",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
@@ -9,6 +9,7 @@
"currentTemperature": 20.8,
"setpoint": 22.0,
"thermostatMode": "Comfort",
"hvacAction": "Heating",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
@@ -31,6 +32,7 @@
"currentTemperature": 19.2,
"setpoint": 21.0,
"thermostatMode": "Program",
"hvacAction": "Idle",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
@@ -9,6 +9,7 @@
"currentTemperature": 20.5,
"setpoint": 22.0,
"thermostatMode": "Comfort",
"hvacAction": "Heating",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
@@ -31,6 +32,7 @@
"currentTemperature": 19.0,
"setpoint": 21.0,
"thermostatMode": "Program",
"hvacAction": "Idle",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
@@ -13,6 +13,12 @@
]),
'max_temp': 30.0,
'min_temp': 5.0,
'preset_modes': list([
'comfort',
'eco',
'defrost',
'timer',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -38,8 +44,8 @@
'platform': 'watts',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'thermostat',
'unique_id': 'thermostat_456',
'unit_of_measurement': None,
})
@@ -49,6 +55,7 @@
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Bedroom Thermostat',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
@@ -56,7 +63,14 @@
]),
'max_temp': 30.0,
'min_temp': 5.0,
'supported_features': <ClimateEntityFeature: 1>,
'preset_mode': None,
'preset_modes': list([
'comfort',
'eco',
'defrost',
'timer',
]),
'supported_features': <ClimateEntityFeature: 17>,
'temperature': 21.0,
}),
'context': <ANY>,
@@ -81,6 +95,12 @@
]),
'max_temp': 30.0,
'min_temp': 5.0,
'preset_modes': list([
'comfort',
'eco',
'defrost',
'timer',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -106,8 +126,8 @@
'platform': 'watts',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'thermostat',
'unique_id': 'thermostat_123',
'unit_of_measurement': None,
})
@@ -117,6 +137,7 @@
'attributes': ReadOnlyDict({
'current_temperature': 20.5,
'friendly_name': 'Living Room Thermostat',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
@@ -124,7 +145,14 @@
]),
'max_temp': 30.0,
'min_temp': 5.0,
'supported_features': <ClimateEntityFeature: 1>,
'preset_mode': 'comfort',
'preset_modes': list([
'comfort',
'eco',
'defrost',
'timer',
]),
'supported_features': <ClimateEntityFeature: 17>,
'temperature': 22.0,
}),
'context': <ANY>,
@@ -30,7 +30,7 @@
'device_id': 'thermostat_123',
'device_name': 'Living Room Thermostat',
'device_type': 'thermostat',
'hvac_action': 'Idle',
'hvac_action': 'Heating',
'interface': 'homeassistant.components.THERMOSTAT',
'is_online': True,
'max_allowed_temperature': 30.0,
@@ -131,7 +131,7 @@
'device_id': 'thermostat_123',
'device_name': 'Living Room Thermostat',
'device_type': 'thermostat',
'hvac_action': 'Idle',
'hvac_action': 'Heating',
'interface': 'homeassistant.components.THERMOSTAT',
'is_online': True,
'max_allowed_temperature': 30.0,
+95
View File
@@ -10,9 +10,11 @@ from visionpluspython.models import ThermostatMode
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
ATTR_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
@@ -188,6 +190,99 @@ async def test_set_hvac_mode_off(
)
async def test_set_preset_mode_comfort(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting preset mode to comfort."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_PRESET_MODE: "comfort",
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_123", ThermostatMode.COMFORT
)
async def test_set_preset_mode_defrost(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting preset mode to defrost."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_PRESET_MODE: "defrost",
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_123", ThermostatMode.DEFROST
)
async def test_set_preset_mode_timer(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting preset mode to timer."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_PRESET_MODE: "timer",
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_123", ThermostatMode.TIMER
)
async def test_set_preset_mode_error(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error handling when setting preset mode fails."""
await setup_integration(hass, mock_config_entry)
mock_watts_client.set_thermostat_mode.side_effect = RuntimeError("API Error")
with pytest.raises(
HomeAssistantError, match="An error occurred while setting the preset mode"
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_PRESET_MODE: "defrost",
},
blocking=True,
)
async def test_set_temperature_api_error(
hass: HomeAssistant,
mock_watts_client: AsyncMock,