Skip to content
16 changes: 15 additions & 1 deletion homeassistant/components/watts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,34 @@
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 (
WattsVisionDeviceCoordinator,
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)
Comment thread
theobld-ww marked this conversation as resolved.
return True


@dataclass
class WattsVisionRuntimeData:
Expand Down
45 changes: 44 additions & 1 deletion homeassistant/components/watts/climate.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +3 to 8

from homeassistant.components.climate import (
Expand All @@ -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

Expand Down Expand Up @@ -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,
Comment on lines +228 to +233
)

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]
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/watts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@
}

SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice)

# Timer service
SERVICE_ACTIVATE_TIMER_MODE = "activate_timer_mode"
ATTR_DURATION = "duration"
5 changes: 5 additions & 0 deletions homeassistant/components/watts/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@
}
}
}
},
"services": {
"activate_timer_mode": {
"service": "mdi:timer"
}
}
}
31 changes: 31 additions & 0 deletions homeassistant/components/watts/services.py
Original file line number Diff line number Diff line change
@@ -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",
)
18 changes: 18 additions & 0 deletions homeassistant/components/watts/services.yaml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions homeassistant/components/watts/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
}
},
"exceptions": {
"activate_timer_mode_error": {
"message": "An error occurred while activating timer mode"
},
"authentication_failed": {
"message": "Authentication failed"
},
Expand Down Expand Up @@ -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"
}
}
}
90 changes: 89 additions & 1 deletion tests/components/watts/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading