diff --git a/.strict-typing b/.strict-typing index ac0c8c38df5..91d91103c91 100644 --- a/.strict-typing +++ b/.strict-typing @@ -567,6 +567,7 @@ homeassistant.components.wake_word.* homeassistant.components.wallbox.* homeassistant.components.waqi.* homeassistant.components.water_heater.* +homeassistant.components.watts.* homeassistant.components.watttime.* homeassistant.components.weather.* homeassistant.components.webhook.* diff --git a/CODEOWNERS b/CODEOWNERS index 8a352f20187..b14d447d314 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1798,6 +1798,8 @@ build.json @home-assistant/supervisor /homeassistant/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero /homeassistant/components/watson_tts/ @rutkai +/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro +/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya /homeassistant/components/waze_travel_time/ @eifinger diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py new file mode 100644 index 00000000000..8cbc90548b1 --- /dev/null +++ b/homeassistant/components/watts/__init__.py @@ -0,0 +1,160 @@ +"""The Watts Vision + integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientResponseError +from visionpluspython.auth import WattsVisionAuth +from visionpluspython.client import WattsVisionClient +from visionpluspython.models import ThermostatDevice + +from homeassistant.config_entries import ConfigEntry +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.dispatcher import async_dispatcher_send + +from .const import DOMAIN +from .coordinator import ( + WattsVisionHubCoordinator, + WattsVisionThermostatCoordinator, + WattsVisionThermostatData, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +@dataclass +class WattsVisionRuntimeData: + """Runtime data for Watts Vision integration.""" + + auth: WattsVisionAuth + hub_coordinator: WattsVisionHubCoordinator + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] + client: WattsVisionClient + + +type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + + +@callback +def _handle_new_thermostats( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, +) -> None: + """Check for new thermostat devices and create coordinators.""" + + current_device_ids = set(hub_coordinator.data.keys()) + known_device_ids = set(entry.runtime_data.thermostat_coordinators.keys()) + new_device_ids = current_device_ids - known_device_ids + + if not new_device_ids: + return + + _LOGGER.info("Discovered %d new device(s): %s", len(new_device_ids), new_device_ids) + + thermostat_coordinators = entry.runtime_data.thermostat_coordinators + client = entry.runtime_data.client + + for device_id in new_device_ids: + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( + hass, client, entry, hub_coordinator, device_id + ) + thermostat_coordinator.async_set_updated_data( + WattsVisionThermostatData(thermostat=device) + ) + thermostat_coordinators[device_id] = thermostat_coordinator + + _LOGGER.debug("Created thermostat coordinator for device %s", device_id) + + async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device") + + +async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: + """Set up Watts Vision from a config entry.""" + + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + "OAuth2 implementation temporarily unavailable" + ) from err + + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + try: + await oauth_session.async_ensure_token_valid() + except ClientResponseError as err: + if HTTPStatus.BAD_REQUEST <= err.status < HTTPStatus.INTERNAL_SERVER_ERROR: + raise ConfigEntryAuthFailed("OAuth session not valid") from err + raise ConfigEntryNotReady("Temporary connection error") from err + except ClientError as err: + raise ConfigEntryNotReady("Network issue during OAuth setup") from err + + session = aiohttp_client.async_get_clientsession(hass) + auth = WattsVisionAuth( + oauth_session=oauth_session, + session=session, + ) + + client = WattsVisionClient(auth, session) + hub_coordinator = WattsVisionHubCoordinator(hass, client, entry) + + await hub_coordinator.async_config_entry_first_refresh() + + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] = {} + for device_id in hub_coordinator.device_ids: + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( + hass, client, entry, hub_coordinator, device_id + ) + thermostat_coordinator.async_set_updated_data( + WattsVisionThermostatData(thermostat=device) + ) + thermostat_coordinators[device_id] = thermostat_coordinator + + entry.runtime_data = WattsVisionRuntimeData( + auth=auth, + hub_coordinator=hub_coordinator, + thermostat_coordinators=thermostat_coordinators, + client=client, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Listener for dynamic device detection + entry.async_on_unload( + hub_coordinator.async_add_listener( + lambda: _handle_new_thermostats(hass, entry, hub_coordinator) + ) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WattsVisionConfigEntry +) -> bool: + """Unload a config entry.""" + for thermostat_coordinator in entry.runtime_data.thermostat_coordinators.values(): + thermostat_coordinator.unsubscribe_hub_listener() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watts/application_credentials.py b/homeassistant/components/watts/application_credentials.py new file mode 100644 index 00000000000..0203d77ad1a --- /dev/null +++ b/homeassistant/components/watts/application_credentials.py @@ -0,0 +1,12 @@ +"""Application credentials for Watts integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + + return AuthorizationServer(authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py new file mode 100644 index 00000000000..e9f21b974f5 --- /dev/null +++ b/homeassistant/components/watts/climate.py @@ -0,0 +1,164 @@ +"""Climate platform for Watts Vision integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from visionpluspython.models import ThermostatDevice + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +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 .coordinator import WattsVisionThermostatCoordinator +from .entity import WattsVisionThermostatEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Watts Vision climate entities from a config entry.""" + + thermostat_coordinators = entry.runtime_data.thermostat_coordinators + known_device_ids: set[str] = set() + + @callback + def _check_new_thermostats() -> None: + """Check for new thermostat devices.""" + current_device_ids = set(thermostat_coordinators.keys()) + new_device_ids = current_device_ids - known_device_ids + + if not new_device_ids: + return + + _LOGGER.debug( + "Adding climate entities for %d new thermostat(s)", + len(new_device_ids), + ) + + new_entities = [ + WattsVisionClimate( + thermostat_coordinators[device_id], + thermostat_coordinators[device_id].data.thermostat, + ) + for device_id in new_device_ids + ] + + known_device_ids.update(new_device_ids) + async_add_entities(new_entities) + + _check_new_thermostats() + + # Listen for new thermostats + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{entry.entry_id}_new_device", + _check_new_thermostats, + ) + ) + + +class WattsVisionClimate(WattsVisionThermostatEntity, ClimateEntity): + """Representation of a Watts Vision heater as a climate entity.""" + + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_name = None + + def __init__( + self, + coordinator: WattsVisionThermostatCoordinator, + thermostat: ThermostatDevice, + ) -> None: + """Initialize the climate entity.""" + + super().__init__(coordinator, thermostat.device_id) + + self._attr_min_temp = thermostat.min_allowed_temperature + self._attr_max_temp = thermostat.max_allowed_temperature + + if thermostat.temperature_unit.upper() == "C": + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + else: + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.thermostat.current_temperature + + @property + def target_temperature(self) -> float | None: + """Return the temperature setpoint.""" + return self.thermostat.setpoint + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + return THERMOSTAT_MODE_TO_HVAC.get(self.thermostat.thermostat_mode) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + try: + await self.coordinator.client.set_thermostat_temperature( + self.device_id, temperature + ) + except RuntimeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_error", + ) from err + + _LOGGER.debug( + "Successfully set temperature to %s for %s", + temperature, + 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] + + 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_hvac_mode_error", + ) from err + + _LOGGER.debug( + "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", + hvac_mode, + mode.name, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py new file mode 100644 index 00000000000..c71e67528aa --- /dev/null +++ b/homeassistant/components/watts/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for Watts Vision integration.""" + +import logging +from typing import Any + +from visionpluspython.auth import WattsVisionAuth + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Watts Vision OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra parameters for OAuth2 authentication.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the OAuth2 flow.""" + + access_token = data["token"]["access_token"] + user_id = WattsVisionAuth.extract_user_id_from_token(access_token) + + if not user_id: + return self.async_abort(reason="invalid_token") + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Watts Vision +", + data=data, + ) diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py new file mode 100644 index 00000000000..8434daca11d --- /dev/null +++ b/homeassistant/components/watts/const.py @@ -0,0 +1,37 @@ +"""Constants for the Watts Vision+ integration.""" + +from visionpluspython.models import ThermostatMode + +from homeassistant.components.climate import HVACMode + +DOMAIN = "watts" + +OAUTH2_AUTHORIZE = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/authorize" +OAUTH2_TOKEN = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/token" + +OAUTH2_SCOPES = [ + "openid", + "offline_access", + "https://visionlogin.onmicrosoft.com/homeassistant-api/homeassistant.read", +] + +# Update intervals +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 Home Assistant HVAC modes to Watts Vision + modes +HVAC_MODE_TO_THERMOSTAT = { + HVACMode.HEAT: ThermostatMode.COMFORT, + HVACMode.OFF: ThermostatMode.OFF, + HVACMode.AUTO: ThermostatMode.PROGRAM, +} diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py new file mode 100644 index 00000000000..5dbb5571c63 --- /dev/null +++ b/homeassistant/components/watts/coordinator.py @@ -0,0 +1,228 @@ +"""Data coordinator for Watts Vision integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import TYPE_CHECKING + +from visionpluspython.client import WattsVisionClient +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) +from visionpluspython.models import Device, ThermostatDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + DISCOVERY_INTERVAL_MINUTES, + DOMAIN, + FAST_POLLING_INTERVAL_SECONDS, + UPDATE_INTERVAL_SECONDS, +) + +if TYPE_CHECKING: + from . import WattsVisionRuntimeData + + type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WattsVisionThermostatData: + """Data class for thermostat device coordinator.""" + + thermostat: ThermostatDevice + + +class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): + """Hub coordinator for bulk device discovery and updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: WattsVisionConfigEntry, + ) -> None: + """Initialize the hub coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + config_entry=config_entry, + ) + self.client = client + self._last_discovery: datetime | None = None + self.previous_devices: set[str] = set() + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch data and periodic device discovery.""" + now = datetime.now() + is_first_refresh = self._last_discovery is None + discovery_interval_elapsed = ( + self._last_discovery is not None + and now - self._last_discovery + >= timedelta(minutes=DISCOVERY_INTERVAL_MINUTES) + ) + + if is_first_refresh or discovery_interval_elapsed: + try: + devices_list = await self.client.discover_devices() + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + if is_first_refresh: + raise ConfigEntryNotReady("Failed to discover devices") from err + _LOGGER.warning( + "Periodic discovery failed: %s, falling back to update", err + ) + else: + self._last_discovery = now + devices = {device.device_id: device for device in devices_list} + + current_devices = set(devices.keys()) + if stale_devices := self.previous_devices - current_devices: + await self._remove_stale_devices(stale_devices) + + self.previous_devices = current_devices + return devices + + # Regular update of existing devices + device_ids = list(self.data.keys()) + if not device_ids: + return {} + + try: + devices = await self.client.get_devices_report(device_ids) + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + raise UpdateFailed("Failed to update devices") from err + + _LOGGER.debug("Updated %d devices", len(devices)) + return devices + + async def _remove_stale_devices(self, stale_device_ids: set[str]) -> None: + """Remove stale devices.""" + assert self.config_entry is not None + device_registry = dr.async_get(self.hass) + + for device_id in stale_device_ids: + _LOGGER.info("Removing stale device: %s", device_id) + + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + @property + def device_ids(self) -> list[str]: + """Get list of all device IDs.""" + return list((self.data or {}).keys()) + + +class WattsVisionThermostatCoordinator( + DataUpdateCoordinator[WattsVisionThermostatData] +): + """Thermostat device coordinator for individual updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: WattsVisionConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, + device_id: str, + ) -> None: + """Initialize the thermostat coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_id}", + update_interval=None, # Manual refresh only + config_entry=config_entry, + ) + self.client = client + self.device_id = device_id + self.hub_coordinator = hub_coordinator + self._fast_polling_until: datetime | None = None + + # Listen to hub coordinator updates + self.unsubscribe_hub_listener = hub_coordinator.async_add_listener( + self._handle_hub_update + ) + + def _handle_hub_update(self) -> None: + """Handle updates from hub coordinator.""" + if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data: + device = self.hub_coordinator.data[self.device_id] + assert isinstance(device, ThermostatDevice) + self.async_set_updated_data(WattsVisionThermostatData(thermostat=device)) + + async def _async_update_data(self) -> WattsVisionThermostatData: + """Refresh specific thermostat device.""" + if self._fast_polling_until and datetime.now() > self._fast_polling_until: + self._fast_polling_until = None + self.update_interval = None + _LOGGER.debug( + "Device %s: Fast polling period ended, returning to manual refresh", + self.device_id, + ) + + try: + device = await self.client.get_device(self.device_id, refresh=True) + except ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + raise UpdateFailed(f"Failed to refresh device {self.device_id}") from err + + if not device: + raise UpdateFailed(f"Device {self.device_id} not found") + + assert isinstance(device, ThermostatDevice) + _LOGGER.debug("Refreshed thermostat %s", self.device_id) + return WattsVisionThermostatData(thermostat=device) + + def trigger_fast_polling(self, duration: int = 60) -> None: + """Activate fast polling for a specified duration after a command.""" + self._fast_polling_until = datetime.now() + timedelta(seconds=duration) + self.update_interval = timedelta(seconds=FAST_POLLING_INTERVAL_SECONDS) + _LOGGER.debug( + "Device %s: Activated fast polling for %d seconds", self.device_id, duration + ) diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py new file mode 100644 index 00000000000..4b429cf4c55 --- /dev/null +++ b/homeassistant/components/watts/entity.py @@ -0,0 +1,43 @@ +"""Base entity for Watts Vision integration.""" + +from __future__ import annotations + +from visionpluspython.models import ThermostatDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WattsVisionThermostatCoordinator + + +class WattsVisionThermostatEntity(CoordinatorEntity[WattsVisionThermostatCoordinator]): + """Base entity for Watts Vision thermostat devices.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: WattsVisionThermostatCoordinator, device_id: str + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator, context=device_id) + self.device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + name=self.thermostat.device_name, + manufacturer="Watts", + model=f"Vision+ {self.thermostat.device_type}", + suggested_area=self.thermostat.room_name, + ) + + @property + def thermostat(self) -> ThermostatDevice: + """Return the thermostat device from the coordinator data.""" + return self.coordinator.data.thermostat + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data.thermostat.is_online diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json new file mode 100644 index 00000000000..40bcf375760 --- /dev/null +++ b/homeassistant/components/watts/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "watts", + "name": "Watts Vision +", + "codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/watts", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["visionpluspython==1.0.2"] +} diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml new file mode 100644 index 00000000000..152dcbbd3f5 --- /dev/null +++ b/homeassistant/components/watts/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not have configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Device doesn't have discoverable properties + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: No entity required translations. + exception-translations: todo + icon-translations: + status: exempt + comment: Thermostat entities use standard HA Climate entity. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No actionable repair scenarios, auth issues are handled by reauthentication flow. + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json new file mode 100644 index 00000000000..967a1167f8f --- /dev/null +++ b/homeassistant/components/watts/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "invalid_token": "The provided access token is invalid.", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + }, + "exceptions": { + "set_hvac_mode_error": { + "message": "An error occurred while setting the HVAC mode." + }, + "set_temperature_error": { + "message": "An error occurred while setting the temperature." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 1f9d3e005a6..0b0663d2183 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -40,6 +40,7 @@ APPLICATION_CREDENTIALS = [ "tesla_fleet", "twitch", "volvo", + "watts", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8b78ae3ac44..0aa3b8869e3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -751,6 +751,7 @@ FLOWS = { "wallbox", "waqi", "watergate", + "watts", "watttime", "waze_travel_time", "weatherflow", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cd7370da44e..5d4df4bdf07 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7472,6 +7472,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "watts": { + "name": "Watts Vision +", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "watttime": { "name": "WattTime", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 93cd23c31a7..e21f8fd44c3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5429,6 +5429,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.watts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.watttime.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 89c3699b01a..ba71a3dcec4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3123,6 +3123,9 @@ victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.2 + # homeassistant.components.caldav vobject==0.9.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52f78d4b892..85b7bbbfc0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2608,6 +2608,9 @@ victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.2 + # homeassistant.components.caldav vobject==0.9.9 diff --git a/tests/components/watts/__init__.py b/tests/components/watts/__init__.py new file mode 100644 index 00000000000..5ce8066f60e --- /dev/null +++ b/tests/components/watts/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Watts Vision integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Watts Vision integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py new file mode 100644 index 00000000000..6e78bc397b2 --- /dev/null +++ b/tests/components/watts/conftest.py @@ -0,0 +1,103 @@ +"""Fixtures for the Watts integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from visionpluspython.models import create_device_from_data + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.watts.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +CLIENT_ID = "test_client_id" +CLIENT_SECRET = "test_client_secret" +TEST_USER_ID = "test-user-id" +TEST_ACCESS_TOKEN = "test-access-token" +TEST_REFRESH_TOKEN = "test-refresh-token" +TEST_ID_TOKEN = "test-id-token" +TEST_PROFILE_INFO = "test-profile-info" +TEST_EXPIRES_AT = 9999999999 + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Ensure the application credentials are registered for each test.""" + assert await async_setup_component(hass, "application_credentials", {}) + + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET, name="Watts"), + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.watts.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_watts_client() -> Generator[AsyncMock]: + """Mock a Watts Vision client.""" + with patch( + "homeassistant.components.watts.WattsVisionClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + + discover_data = load_json_array_fixture("discover_devices.json", DOMAIN) + device_report_data = load_json_object_fixture("device_report.json", DOMAIN) + device_detail_data = load_json_object_fixture("device_detail.json", DOMAIN) + + discovered_devices = [ + create_device_from_data(device_data) # type: ignore[arg-type] + for device_data in discover_data + ] + device_report = { + device_id: create_device_from_data(device_data) # type: ignore[arg-type] + for device_id, device_data in device_report_data.items() + } + device_detail = create_device_from_data(device_detail_data) # type: ignore[arg-type] + + client.discover_devices.return_value = discovered_devices + client.get_devices_report.return_value = device_report + client.get_device.return_value = device_detail + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Watts Vision", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "id_token": TEST_ID_TOKEN, + "profile_info": TEST_PROFILE_INFO, + "expires_at": TEST_EXPIRES_AT, + }, + }, + entry_id="01J0BC4QM2YBRP6H5G933CETI8", + unique_id=TEST_USER_ID, + ) diff --git a/tests/components/watts/fixtures/device_detail.json b/tests/components/watts/fixtures/device_detail.json new file mode 100644 index 00000000000..dc9633d15c9 --- /dev/null +++ b/tests/components/watts/fixtures/device_detail.json @@ -0,0 +1,22 @@ +{ + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 21.0, + "setpoint": 23.5, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] +} diff --git a/tests/components/watts/fixtures/device_report.json b/tests/components/watts/fixtures/device_report.json new file mode 100644 index 00000000000..bf3467e769e --- /dev/null +++ b/tests/components/watts/fixtures/device_report.json @@ -0,0 +1,39 @@ +{ + "thermostat_123": { + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 20.8, + "setpoint": 22.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] + }, + "thermostat_456": { + "deviceId": "thermostat_456", + "deviceName": "Bedroom Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Bedroom", + "isOnline": true, + "currentTemperature": 19.2, + "setpoint": 21.0, + "thermostatMode": "Program", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"] + } +} diff --git a/tests/components/watts/fixtures/discover_devices.json b/tests/components/watts/fixtures/discover_devices.json new file mode 100644 index 00000000000..0bb36039918 --- /dev/null +++ b/tests/components/watts/fixtures/discover_devices.json @@ -0,0 +1,39 @@ +[ + { + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 20.5, + "setpoint": 22.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] + }, + { + "deviceId": "thermostat_456", + "deviceName": "Bedroom Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Bedroom", + "isOnline": true, + "currentTemperature": 19.0, + "setpoint": 21.0, + "thermostatMode": "Program", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"] + } +] diff --git a/tests/components/watts/snapshots/test_climate.ambr b/tests/components/watts/snapshots/test_climate.ambr new file mode 100644 index 00000000000..88417d17cbb --- /dev/null +++ b/tests/components/watts/snapshots/test_climate.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_entities[climate.bedroom_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'watts', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'thermostat_456', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.bedroom_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Bedroom Thermostat', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.bedroom_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entities[climate.living_room_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'watts', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'thermostat_123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.living_room_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Living Room Thermostat', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.living_room_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/watts/test_application_credentials.py b/tests/components/watts/test_application_credentials.py new file mode 100644 index 00000000000..35242145edd --- /dev/null +++ b/tests/components/watts/test_application_credentials.py @@ -0,0 +1,15 @@ +"""Test application credentials for Watts integration.""" + +from homeassistant.components.watts.application_credentials import ( + async_get_authorization_server, +) +from homeassistant.components.watts.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant + + +async def test_async_get_authorization_server(hass: HomeAssistant) -> None: + """Test getting authorization server.""" + auth_server = await async_get_authorization_server(hass) + + assert auth_server.authorize_url == OAUTH2_AUTHORIZE + assert auth_server.token_url == OAUTH2_TOKEN diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py new file mode 100644 index 00000000000..aa8b40aec0f --- /dev/null +++ b/tests/components/watts/test_climate.py @@ -0,0 +1,264 @@ +"""Tests for the Watts Vision climate platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from visionpluspython.models import ThermostatMode + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the climate entities.""" + with patch("homeassistant.components.watts.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room_thermostat") + assert state is not None + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_temperature.assert_called_once_with( + "thermostat_123", 23.5 + ) + + +async def test_set_temperature_triggers_fast_polling( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that setting temperature triggers fast polling.""" + await setup_integration(hass, mock_config_entry) + + # Trigger fast polling + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + # Reset mock to count only fast polling calls + mock_watts_client.get_device.reset_mock() + + # Advance time by 5 seconds (fast polling interval) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_watts_client.get_device.called + mock_watts_client.get_device.assert_called_with("thermostat_123", refresh=True) + + +async def test_fast_polling_stops_after_duration( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that fast polling stops after the duration expires.""" + await setup_integration(hass, mock_config_entry) + + # Trigger fast polling + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + # Reset mock to count only fast polling calls + mock_watts_client.get_device.reset_mock() + + # Should be in fast polling 55s after + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_watts_client.get_device.called + + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should be called one last time to check if duration expired, then stop + + # Fast polling should be done now + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not mock_watts_client.get_device.called + + +async def test_set_hvac_mode_heat( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to heat.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.COMFORT + ) + + +async def test_set_hvac_mode_auto( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to auto.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bedroom_thermostat", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_456", ThermostatMode.PROGRAM + ) + + +async def test_set_hvac_mode_off( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.OFF + ) + + +async def test_set_temperature_api_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when setting temperature fails.""" + await setup_integration(hass, mock_config_entry) + + # Make the API call fail + mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the temperature" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + +async def test_set_hvac_mode_value_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when setting mode fails.""" + await setup_integration(hass, mock_config_entry) + + mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the HVAC mode" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py new file mode 100644 index 00000000000..8b56bda1ae1 --- /dev/null +++ b/tests/components/watts/test_config_flow.py @@ -0,0 +1,247 @@ +"""Test the Watts Vision config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.watts.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the full OAuth2 config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.EXTERNAL_STEP + assert "url" in result + assert OAUTH2_AUTHORIZE in result.get("url", "") + assert "response_type=code" in result.get("url", "") + assert "scope=" in result.get("url", "") + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Watts Vision +" + assert "token" in result.get("data", {}) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "user123" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_invalid_token_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the OAuth2 config flow with invalid token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "invalid-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "invalid_token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth error handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={"error": "invalid_grant"}, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_error" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_timeout( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth timeout handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post(OAUTH2_TOKEN, exc=TimeoutError()) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_timeout" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_invalid_response( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth invalid response handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post(OAUTH2_TOKEN, status=500, text="invalid json") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_failed" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_unique_config_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that duplicate config entries are not allowed.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="user123", + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py new file mode 100644 index 00000000000..98a85690972 --- /dev/null +++ b/tests/components/watts/test_init.py @@ -0,0 +1,275 @@ +"""Test the Watts Vision integration initialization.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) +from visionpluspython.models import create_device_from_data + +from homeassistant.components.watts.const import ( + DISCOVERY_INTERVAL_MINUTES, + DOMAIN, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_watts_client.discover_devices.assert_called_once() + + unload_result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert unload_result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup with authentication failure.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, status=401) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_not_ready( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup when network is temporarily unavailable.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, exc=ClientError("Connection timeout")) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_hub_coordinator_update_failed( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup when hub coordinator update fails.""" + + # Make discover_devices fail + mock_watts_client.discover_devices.side_effect = ConnectionError("API error") + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_server_error_5xx( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup when server returns error.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, status=500) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (WattsVisionAuthError("Auth failed"), ConfigEntryState.SETUP_ERROR), + (WattsVisionConnectionError("Connection lost"), ConfigEntryState.SETUP_RETRY), + (WattsVisionTimeoutError("Request timeout"), ConfigEntryState.SETUP_RETRY), + (WattsVisionDeviceError("Device error"), ConfigEntryState.SETUP_RETRY), + (WattsVisionError("API error"), ConfigEntryState.SETUP_RETRY), + (ValueError("Value error"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_discover_devices_errors( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup errors during device discovery.""" + mock_watts_client.discover_devices.side_effect = exception + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert mock_config_entry.state is expected_state + + +async def test_dynamic_device_creation( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are created dynamically.""" + await setup_integration(hass, mock_config_entry) + + assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_123")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_456")}) + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_789")}) + is None + ) + + new_device_data = { + "deviceId": "thermostat_789", + "deviceName": "Kitchen Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Kitchen", + "isOnline": True, + "currentTemperature": 21.0, + "setpoint": 20.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"], + } + new_device = create_device_from_data(new_device_data) + + current_devices = list(mock_watts_client.discover_devices.return_value) + mock_watts_client.discover_devices.return_value = [*current_devices, new_device] + + freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + new_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_789")} + ) + assert new_device_entry is not None + assert new_device_entry.name == "Kitchen Thermostat" + + state = hass.states.get("climate.kitchen_thermostat") + assert state is not None + + +async def test_stale_device_removal( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test stale devices are removed dynamically.""" + await setup_integration(hass, mock_config_entry) + + device_123 = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_123")} + ) + device_456 = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_456")} + ) + assert device_123 is not None + assert device_456 is not None + + current_devices = list(mock_watts_client.discover_devices.return_value) + # remove thermostat_456 + mock_watts_client.discover_devices.return_value = [ + d for d in current_devices if d.device_id != "thermostat_456" + ] + + freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify thermostat_456 has been removed + device_456_after_removal = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_456")} + ) + assert device_456_after_removal is None