diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 92cc21e862d687..3dbb42371de5d6 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -12,8 +12,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SUPPORTED_DEVICE_TYPES from .coordinator import ( @@ -21,11 +26,20 @@ WattsVisionDeviceData, WattsVisionHubCoordinator, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Watts Vision component.""" + async_setup_services(hass) + return True + @dataclass class WattsVisionRuntimeData: diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 1094e66589940a..e56d4c4bebe9c1 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -1,8 +1,10 @@ """Climate platform for Watts Vision integration.""" +from datetime import timedelta import logging from typing import Any +from visionpluspython.exceptions import WattsVisionError from visionpluspython.models import ThermostatDevice, ThermostatMode from homeassistant.components.climate import ( @@ -13,7 +15,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -194,6 +196,47 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self.coordinator.async_refresh() + async def async_activate_timer_mode( + self, temperature: float, duration: timedelta + ) -> None: + """Activate timer mode with a target temperature and duration.""" + if not self._attr_min_temp <= temperature <= self._attr_max_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="timer_temperature_out_of_range", + translation_placeholders={ + "temperature": str(temperature), + "min_temp": str(self._attr_min_temp), + "max_temp": str(self._attr_max_temp), + }, + ) + + duration_minutes, remainder = divmod(duration, timedelta(minutes=1)) + if remainder: + duration_minutes += 1 + + try: + await self.coordinator.client.activate_thermostat_timer( + self.device_id, temperature, duration_minutes + ) + except (WattsVisionError, ValueError, RuntimeError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="activate_timer_mode_error", + ) from err + + _LOGGER.debug( + "Successfully activated timer mode: %s%s for %d min on %s", + temperature, + self.temperature_unit, + duration_minutes, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode] diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index 597b910ae40c3f..e1ba4a0134e09b 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -67,3 +67,7 @@ } SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice) + +# Timer service +SERVICE_ACTIVATE_TIMER_MODE = "activate_timer_mode" +ATTR_DURATION = "duration" diff --git a/homeassistant/components/watts/icons.json b/homeassistant/components/watts/icons.json index 191ab307bf8222..58eb4bd0445cc2 100644 --- a/homeassistant/components/watts/icons.json +++ b/homeassistant/components/watts/icons.json @@ -14,5 +14,10 @@ } } } + }, + "services": { + "activate_timer_mode": { + "service": "mdi:timer" + } } } diff --git a/homeassistant/components/watts/services.py b/homeassistant/components/watts/services.py new file mode 100644 index 00000000000000..e7659c6e47a665 --- /dev/null +++ b/homeassistant/components/watts/services.py @@ -0,0 +1,31 @@ +"""Services for Watts Vision integration.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ATTR_DURATION, DOMAIN, SERVICE_ACTIVATE_TIMER_MODE + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the Watts Vision integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), + vol.Required(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=1), max=timedelta(days=1)), + ), + }, + func="async_activate_timer_mode", + ) diff --git a/homeassistant/components/watts/services.yaml b/homeassistant/components/watts/services.yaml new file mode 100644 index 00000000000000..f252528a94f34a --- /dev/null +++ b/homeassistant/components/watts/services.yaml @@ -0,0 +1,18 @@ +activate_timer_mode: + target: + entity: + domain: climate + integration: watts + fields: + temperature: + required: true + selector: + number: + step: 0.5 + unit_of_measurement: "°" + mode: box + duration: + required: true + selector: + duration: + enable_second: false diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index 01734bb221442b..5ae5f6eb988424 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -45,6 +45,9 @@ } }, "exceptions": { + "activate_timer_mode_error": { + "message": "An error occurred while activating timer mode" + }, "authentication_failed": { "message": "Authentication failed" }, @@ -83,6 +86,25 @@ }, "temporary_connection_error": { "message": "Temporary connection error" + }, + "timer_temperature_out_of_range": { + "message": "Timer temperature {temperature} is out of range ({min_temp}-{max_temp})" + } + }, + "services": { + "activate_timer_mode": { + "description": "Activates timer mode on the thermostat for a specified temperature and duration.", + "fields": { + "duration": { + "description": "Duration of the timer.", + "name": "Duration" + }, + "temperature": { + "description": "Target temperature while the timer is active.", + "name": "Temperature" + } + }, + "name": "Activate timer mode" } } } diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 29933f315afbef..a8194e9a4d4976 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -18,9 +18,14 @@ SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.watts.const import ( + ATTR_DURATION, + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -283,6 +288,89 @@ async def test_set_preset_mode_error( ) +@pytest.mark.parametrize( + ("duration", "expected_minutes"), + [ + (timedelta(minutes=90), 90), + (timedelta(minutes=1, seconds=30), 2), + ], +) +async def test_activate_timer_mode( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + duration: timedelta, + expected_minutes: int, +) -> None: + """Test activating timer mode with temperature and duration.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 20.5, + ATTR_DURATION: duration, + }, + blocking=True, + ) + + mock_watts_client.activate_thermostat_timer.assert_called_once_with( + "thermostat_123", 20.5, expected_minutes + ) + + +@pytest.mark.parametrize("temperature", [4.5, 30.5]) +async def test_activate_timer_mode_temperature_out_of_range( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + temperature: float, +) -> None: + """Test that out-of-range timer temperatures are rejected.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="out of range"): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: temperature, + ATTR_DURATION: timedelta(minutes=90), + }, + blocking=True, + ) + + mock_watts_client.activate_thermostat_timer.assert_not_called() + + +async def test_activate_timer_mode_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when activating timer mode fails.""" + await setup_integration(hass, mock_config_entry) + + mock_watts_client.activate_thermostat_timer.side_effect = RuntimeError("API Error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while activating timer mode" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 20.5, + ATTR_DURATION: timedelta(minutes=90), + }, + blocking=True, + ) + + async def test_set_temperature_api_error( hass: HomeAssistant, mock_watts_client: AsyncMock,