From f49e2e94cb6ab3b501c6360d9c7edc0a7fdeb03d Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Fri, 26 Sep 2025 06:25:55 +0000 Subject: [PATCH 01/43] Add Watts Vision + integration with tests --- CODEOWNERS | 2 + homeassistant/components/watts/__init__.py | 123 ++++ .../watts/application_credentials.py | 12 + homeassistant/components/watts/auth.py | 25 + homeassistant/components/watts/climate.py | 160 +++++ homeassistant/components/watts/config_flow.py | 53 ++ homeassistant/components/watts/const.py | 34 ++ homeassistant/components/watts/coordinator.py | 94 +++ homeassistant/components/watts/entity.py | 52 ++ homeassistant/components/watts/manifest.json | 13 + .../components/watts/quality_scale.yaml | 60 ++ homeassistant/components/watts/strings.json | 25 + homeassistant/components/watts/switch.py | 96 +++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/watts/__init__.py | 1 + tests/components/watts/conftest.py | 27 + .../watts/test_application_credentials.py | 15 + tests/components/watts/test_auth.py | 39 ++ tests/components/watts/test_climate.py | 559 ++++++++++++++++++ tests/components/watts/test_config_flow.py | 343 +++++++++++ tests/components/watts/test_coordinator.py | 164 +++++ tests/components/watts/test_init.py | 357 +++++++++++ tests/components/watts/test_switch.py | 351 +++++++++++ 27 files changed, 2619 insertions(+) create mode 100644 homeassistant/components/watts/__init__.py create mode 100644 homeassistant/components/watts/application_credentials.py create mode 100644 homeassistant/components/watts/auth.py create mode 100644 homeassistant/components/watts/climate.py create mode 100644 homeassistant/components/watts/config_flow.py create mode 100644 homeassistant/components/watts/const.py create mode 100644 homeassistant/components/watts/coordinator.py create mode 100644 homeassistant/components/watts/entity.py create mode 100644 homeassistant/components/watts/manifest.json create mode 100644 homeassistant/components/watts/quality_scale.yaml create mode 100644 homeassistant/components/watts/strings.json create mode 100644 homeassistant/components/watts/switch.py create mode 100644 tests/components/watts/__init__.py create mode 100644 tests/components/watts/conftest.py create mode 100644 tests/components/watts/test_application_credentials.py create mode 100644 tests/components/watts/test_auth.py create mode 100644 tests/components/watts/test_climate.py create mode 100644 tests/components/watts/test_config_flow.py create mode 100644 tests/components/watts/test_coordinator.py create mode 100644 tests/components/watts/test_init.py create mode 100644 tests/components/watts/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 1ceb6ff0e7de0f..d7d01338e49198 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1711,6 +1711,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 00000000000000..7487a7c05d604c --- /dev/null +++ b/homeassistant/components/watts/__init__.py @@ -0,0 +1,123 @@ +"""The Watts Vision + integration.""" + +from __future__ import annotations + +from http import HTTPStatus +import logging +from typing import TypedDict + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.update_coordinator import UpdateFailed +from visionpluspython.auth import WattsVisionAuth +from visionpluspython.client import WattsVisionClient + +from .coordinator import WattsVisionCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] + + +class WattsVisionRuntimeData(TypedDict): + """Runtime data for Watts Vision integration.""" + + auth: WattsVisionAuth + coordinator: WattsVisionCoordinator + client: WattsVisionClient + + +type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: + """Set up Watts Vision from a config entry.""" + + _LOGGER.debug("Setting up Watts Vision integration") + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + 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 + + auth = WattsVisionAuth( + oauth_session=oauth_session, + session=aiohttp_client.async_get_clientsession(hass), + ) + + client = WattsVisionClient(auth) + coordinator = WattsVisionCoordinator(hass, client) + + try: + await coordinator.async_config_entry_first_refresh() + except UpdateFailed as err: + raise ConfigEntryNotReady("Failed to fetch initial data") from err + + entry.runtime_data = WattsVisionRuntimeData( + auth=auth, + coordinator=coordinator, + client=client, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WattsVisionConfigEntry +) -> bool: + """Unload a config entry.""" + + _LOGGER.debug("Unloading Watts Vision + integration") + runtime_data = entry.runtime_data + + client = runtime_data["client"] + if client: + try: + await client.close() + _LOGGER.debug("Client closed successfully") + except OSError as err: + _LOGGER.warning("Error closing client: %s", err) + + auth = runtime_data["auth"] + if auth: + try: + await auth.close() + _LOGGER.debug("Auth closed successfully") + except OSError as err: + _LOGGER.warning("Error closing auth: %s", err) + + coordinator = runtime_data["coordinator"] + if coordinator: + try: + await coordinator.async_shutdown() + _LOGGER.debug("Coordinator closed successfully") + except (OSError, AttributeError) as err: + _LOGGER.warning("Error closing coordinator: %s", err) + + unload_result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if not unload_result: + _LOGGER.error("Failed to unload platforms for Watts Vision + integration") + else: + _LOGGER.debug("Successfully unloaded platforms for Watts Vision + integration") + + return unload_result diff --git a/homeassistant/components/watts/application_credentials.py b/homeassistant/components/watts/application_credentials.py new file mode 100644 index 00000000000000..0203d77ad1a13b --- /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/auth.py b/homeassistant/components/watts/auth.py new file mode 100644 index 00000000000000..7a3ec82c985890 --- /dev/null +++ b/homeassistant/components/watts/auth.py @@ -0,0 +1,25 @@ +"""Authentication for Watts Vision+ using OAuth2 config entry.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +class ConfigEntryAuth: + """Provide Watts Vision+ authentication with OAuth2 config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Watts Vision+ Auth.""" + + self.hass = hass + self.session = oauth_session + self.token = self.session.token + + async def refresh_tokens(self) -> str: + """Refresh and return new Watts Vision+ tokens.""" + + await self.session.async_ensure_token_valid() + return self.session.token["access_token"] diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py new file mode 100644 index 00000000000000..a237fc49d18301 --- /dev/null +++ b/homeassistant/components/watts/climate.py @@ -0,0 +1,160 @@ +"""Climate platform for Watts Vision integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from visionpluspython.models import ThermostatDevice + +from . import WattsVisionConfigEntry +from .const import ( + HVAC_MODE_TO_THERMOSTAT, + THERMOSTAT_MODE_TO_HVAC, + UPDATE_DELAY_AFTER_COMMAND, +) +from .coordinator import WattsVisionCoordinator +from .entity import WattsVisionEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Watts Vision climate entities from a config entry.""" + + coordinator: WattsVisionCoordinator = entry.runtime_data["coordinator"] + + entities = [] + for device in coordinator.data.values(): + if isinstance(device, ThermostatDevice): + entities.append(WattsVisionClimate(coordinator, device)) + _LOGGER.debug("Created climate entity for device %s", device.device_id) + + if entities: + async_add_entities(entities, update_before_add=True) + _LOGGER.info("Added %d climate entities", len(entities)) + + +class WattsVisionClimate(WattsVisionEntity, 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] + + def __init__( + self, + coordinator: WattsVisionCoordinator, + device: ThermostatDevice, + ) -> None: + """Initialize the climate entity.""" + + super().__init__(coordinator, device.device_id) + self._device = device + self._device_id = device.device_id + + self._attr_name = None + + self._attr_min_temp = device.min_allowed_temperature + self._attr_max_temp = device.max_allowed_temperature + + if device.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.""" + device = self.coordinator.data.get(self._device_id) + if isinstance(device, ThermostatDevice): + return device.current_temperature + return None + + @property + def target_temperature(self) -> float | None: + """Return the temperature setpoint.""" + device = self.coordinator.data.get(self._device_id) + if isinstance(device, ThermostatDevice): + return device.setpoint + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + device = self.coordinator.data.get(self._device_id) + if isinstance(device, ThermostatDevice): + return THERMOSTAT_MODE_TO_HVAC.get(device.thermostat_mode) + return None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + device = self.coordinator.data.get(self._device_id) + if not isinstance(device, ThermostatDevice): + return {} + + return { + "thermostat_mode": device.thermostat_mode, + "device_type": device.device_type, + "room_name": device.room_name, + "temperature_unit": device.temperature_unit, + "available_thermostat_modes": device.available_thermostat_modes, + } + + 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 + ) + _LOGGER.debug( + "Successfully set temperature to %s for %s", + temperature, + self._attr_name, + ) + + await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + await self.coordinator.async_refresh_device(self._device_id) + + except RuntimeError as err: + _LOGGER.error("Error setting temperature for %s: %s", self._attr_name, err) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + mode = HVAC_MODE_TO_THERMOSTAT.get(hvac_mode) + if mode is None: + _LOGGER.error("Unsupported HVAC mode %s for %s", hvac_mode, self._attr_name) + return + + try: + await self.coordinator.client.set_thermostat_mode(self._device_id, mode) + _LOGGER.debug( + "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", + hvac_mode, + mode.name, + self._attr_name, + ) + + await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + await self.coordinator.async_refresh_device(self._device_id) + + except (ValueError, RuntimeError) as err: + _LOGGER.error("Error setting HVAC mode for %s: %s", self._attr_name, err) diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py new file mode 100644 index 00000000000000..28f8cf3d6c7bac --- /dev/null +++ b/homeassistant/components/watts/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow for Watts Vision integration.""" + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from visionpluspython.auth import WattsVisionAuth + +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_step_user(self, user_input=None) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + return await super().async_step_user(user_input) + + 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(f"watts_vision_{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 00000000000000..45f594dc2a8ea7 --- /dev/null +++ b/homeassistant/components/watts/const.py @@ -0,0 +1,34 @@ +"""Constants for the Watts Vision+ integration.""" + +from homeassistant.components.climate import HVACMode +from visionpluspython.models import ThermostatMode + +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_INTERVAL = 30 +UPDATE_DELAY_AFTER_COMMAND = 7 + +# 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 00000000000000..804f60c32ae03e --- /dev/null +++ b/homeassistant/components/watts/coordinator.py @@ -0,0 +1,94 @@ +"""Data coordinator for Watts Vision integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from visionpluspython.client import WattsVisionClient +from visionpluspython.models import Device + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class WattsVisionCoordinator(DataUpdateCoordinator): + """Class to fetch Watts Vision+ data.""" + + def __init__(self, hass: HomeAssistant, client: WattsVisionClient) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + self.client = client + self._devices: dict[str, Device] = {} + self._is_initialized = False + + async def async_config_entry_first_refresh(self) -> None: + """Perform initial discovery of devices.""" + try: + await self._discover_devices() + self.async_set_updated_data(self._devices) + except (ConnectionError, TimeoutError, ValueError) as err: + _LOGGER.error("Initial device discovery failed: %s", err) + raise UpdateFailed(f"Initial discovery failed: {err}") from err + + async def _discover_devices(self) -> None: + """Discover devices from API.""" + devices_list = await self.client.discover_devices() + self._devices = {device.device_id: device for device in devices_list} + self._is_initialized = True + _LOGGER.info("Initial discovery completed with %d devices", len(self._devices)) + + async def async_refresh_device(self, device_id: str) -> None: + """Refresh a specific device.""" + try: + device = await self.client.get_device(device_id, refresh=True) + if device: + self._devices[device_id] = device + self.async_set_updated_data(self._devices) + _LOGGER.debug("Refreshed device %s", device_id) + except (ConnectionError, TimeoutError, ValueError) as err: + _LOGGER.error("Failed to refresh device %s: %s", device_id, err) + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch data from Watts Vision API.""" + try: + if not self._is_initialized: + # First loading, discover devices + await self._discover_devices() + else: + device_ids = list(self._devices.keys()) + + if not device_ids: + _LOGGER.warning("No devices to update") + else: + updated_devices = await self.client.get_devices_report(device_ids) + + for device_id, device in updated_devices.items(): + self._devices[device_id] = device + + _LOGGER.debug("Updated %d devices", len(updated_devices)) + + except (ConnectionError, TimeoutError, ValueError) as err: + _LOGGER.error("API error during devices update: %s", err) + raise UpdateFailed(f"API error during devices update: {err}") from err + else: + return self._devices + + async def async_shutdown(self) -> None: + """Shutdown the coordinator and cleanup resources.""" + self._devices.clear() + self._is_initialized = False + _LOGGER.debug("Coordinator resources cleaned up") + + @property + def device_ids(self) -> list[str]: + """Get list of all device IDs.""" + return list(self._devices.keys()) diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py new file mode 100644 index 00000000000000..4a22c521c2a543 --- /dev/null +++ b/homeassistant/components/watts/entity.py @@ -0,0 +1,52 @@ +"""Base entity for Watts Vision integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WattsVisionCoordinator + + +class WattsVisionEntity(CoordinatorEntity[WattsVisionCoordinator]): + """Base entity for Watts Vision integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + self.device_id = device_id + self._attr_unique_id = device_id + + device = coordinator.data.get(device_id) + if device: + self._attr_name = getattr(device, "device_name", None) + + @property + def device_info(self) -> DeviceInfo | None: + """Return device information.""" + + device = self.coordinator.data.get(self.device_id) + if device: + return DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + name=device.device_name, + manufacturer="Watts", + model=f"Vision+ {device.device_type}", + ) + return None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + + if not self.coordinator.last_update_success: + return False + + device = self.coordinator.data.get(self.device_id) + if device: + return device.is_online + return False diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json new file mode 100644 index 00000000000000..d615f9f5f6ded7 --- /dev/null +++ b/homeassistant/components/watts/manifest.json @@ -0,0 +1,13 @@ +{ + "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.0"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml new file mode 100644 index 00000000000000..76b8d347408bf3 --- /dev/null +++ b/homeassistant/components/watts/quality_scale.yaml @@ -0,0 +1,60 @@ +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: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json new file mode 100644 index 00000000000000..79421b6ae74f05 --- /dev/null +++ b/homeassistant/components/watts/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "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%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "invalid_token": "The provided access token is invalid." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/watts/switch.py b/homeassistant/components/watts/switch.py new file mode 100644 index 00000000000000..5405eda64f25d2 --- /dev/null +++ b/homeassistant/components/watts/switch.py @@ -0,0 +1,96 @@ +"""Switch platform for Watts Vision integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from visionpluspython.models import SwitchDevice + +from . import WattsVisionConfigEntry +from .const import UPDATE_DELAY_AFTER_COMMAND +from .coordinator import WattsVisionCoordinator +from .entity import WattsVisionEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Watts Vision switch entities from a config entry.""" + + coordinator: WattsVisionCoordinator = entry.runtime_data["coordinator"] + + entities = [] + for device in coordinator.data.values(): + if isinstance(device, SwitchDevice): + entities.append(WattsVisionSwitch(coordinator, device)) + _LOGGER.debug("Created switch entity for device %s", device.device_id) + + if entities: + async_add_entities(entities, update_before_add=True) + _LOGGER.info("Added %d switch entities", len(entities)) + + +class WattsVisionSwitch(WattsVisionEntity, SwitchEntity): + """Watts Vision switch device as a switch entity.""" + + def __init__( + self, + coordinator: WattsVisionCoordinator, + device: SwitchDevice, + ) -> None: + """Initialize the switch entity.""" + + super().__init__(coordinator, device.device_id) + self._device = device + self._device_id = device.device_id + self._attr_name = None + + @property + def is_on(self) -> bool | None: + """Return True if the switch is on.""" + device = self.coordinator.data.get(self._device_id) + if isinstance(device, SwitchDevice): + return device.is_turned_on + return None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + device = self.coordinator.data.get(self._device_id) + if not isinstance(device, SwitchDevice): + return {} + + return {"device_type": device.device_type, "room_name": device.room_name} + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.coordinator.client.set_switch_state(self._device_id, True) + _LOGGER.debug("Successfully turned on switch %s", self._attr_name) + + await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + await self.coordinator.async_refresh_device(self._device_id) + + except RuntimeError as err: + _LOGGER.error("Error turning on switch %s: %s", self._attr_name, err) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.coordinator.client.set_switch_state(self._device_id, False) + _LOGGER.debug("Successfully turned off switch %s", self._attr_name) + + await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + await self.coordinator.async_refresh_device(self._device_id) + + except RuntimeError as err: + _LOGGER.error("Error turning off switch %s: %s", self._attr_name, err) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 2f088716f8c4bd..0142223f54c176 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -35,6 +35,7 @@ "spotify", "tesla_fleet", "twitch", + "watts", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 119830b6111502..782cecaa2b781a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -701,6 +701,7 @@ "wallbox", "waqi", "watergate", + "watts", "watttime", "waze_travel_time", "weatherflow", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3795bd838ea145..9b37d1bc350cf2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7286,6 +7286,12 @@ "config_flow": true, "iot_class": "local_push" }, + "watts": { + "name": "Watts Vision +", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "watttime": { "name": "WattTime", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 0c485b79a81201..af11ae62f92ae0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3037,6 +3037,9 @@ venstarcolortouch==0.19 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.0 + # homeassistant.components.voip voip-utils==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51b55d206af48d..b6935d31886d8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2502,6 +2502,9 @@ venstarcolortouch==0.19 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.0 + # homeassistant.components.voip voip-utils==0.3.2 diff --git a/tests/components/watts/__init__.py b/tests/components/watts/__init__.py new file mode 100644 index 00000000000000..f1ea49557b5100 --- /dev/null +++ b/tests/components/watts/__init__.py @@ -0,0 +1 @@ +"""Tests for the Watts integration.""" diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py new file mode 100644 index 00000000000000..5bf39949b670cc --- /dev/null +++ b/tests/components/watts/conftest.py @@ -0,0 +1,27 @@ +"""Fixtures for the Watts integration tests.""" + +import pytest + +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 + +CLIENT_ID = "test_client_id" +CLIENT_SECRET = "test_client_secret" + + +@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"), + ) diff --git a/tests/components/watts/test_application_credentials.py b/tests/components/watts/test_application_credentials.py new file mode 100644 index 00000000000000..35242145edd015 --- /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_auth.py b/tests/components/watts/test_auth.py new file mode 100644 index 00000000000000..71d4914b71c300 --- /dev/null +++ b/tests/components/watts/test_auth.py @@ -0,0 +1,39 @@ +"""Test authentication for Watts Vision+ integration.""" + +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.watts.auth import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +async def test_config_entry_auth_initialization(hass: HomeAssistant) -> None: + """Test ConfigEntryAuth initialization.""" + mock_session = MagicMock(spec=config_entry_oauth2_flow.OAuth2Session) + mock_session.token = { + "access_token": "test_token", + "refresh_token": "refresh_token", + } + + auth = ConfigEntryAuth(hass, mock_session) + + assert auth.hass == hass + assert auth.session == mock_session + assert auth.token == mock_session.token + + +async def test_refresh_tokens(hass: HomeAssistant) -> None: + """Test token refresh.""" + mock_session = MagicMock(spec=config_entry_oauth2_flow.OAuth2Session) + mock_session.token = { + "access_token": "new_access_token", + "refresh_token": "refresh_token", + } + mock_session.async_ensure_token_valid = AsyncMock() + + auth = ConfigEntryAuth(hass, mock_session) + + result = await auth.refresh_tokens() + + assert result == "new_access_token" + mock_session.async_ensure_token_valid.assert_called_once() diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py new file mode 100644 index 00000000000000..f09c23205926e5 --- /dev/null +++ b/tests/components/watts/test_climate.py @@ -0,0 +1,559 @@ +"""Tests for the Watts Vision climate platform.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode + +from homeassistant.components.climate import HVACMode +from homeassistant.components.watts.climate import WattsVisionClimate, async_setup_entry +from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import MockConfigEntry + + +def create_coordinator(devices=None): + """Create a mock coordinator.""" + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.data = devices or {} + coordinator.client = MagicMock() + coordinator.client.set_thermostat_temperature = AsyncMock() + coordinator.client.set_thermostat_mode = AsyncMock() + coordinator.async_refresh_device = AsyncMock() + coordinator.last_update_success = True + return coordinator + + +@pytest.fixture +def mock_hass(): + """Mock HomeAssistant instance.""" + return MagicMock(spec=HomeAssistant) + + +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry.""" + return MockConfigEntry(domain="watts") + + +@pytest.fixture +def mock_thermostat_device(): + """Mock Watts Vision thermostat device.""" + device = MagicMock(spec=ThermostatDevice) + device.device_id = "thermostat_123" + device.device_name = "Test Thermostat" + device.current_temperature = 20.5 + device.setpoint = 22.0 + device.thermostat_mode = "Comfort" + device.min_allowed_temperature = 5.0 + device.max_allowed_temperature = 30.0 + device.temperature_unit = "C" + device.is_online = True + device.device_type = "thermostat" + device.room_name = "Living Room" + device.available_thermostat_modes = [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer", + ] + return device + + +@pytest.fixture +def mock_switch_device(): + """Mock Watts Vision switch device.""" + device = MagicMock(spec=SwitchDevice) + device.device_id = "switch_123" + device.device_name = "Test Switch" + device.is_turned_on = True + device.is_online = True + device.device_type = "switch" + device.room_name = "Kitchen" + return device + + +async def test_climate_initialization(mock_thermostat_device) -> None: + """Test climate entity initialization.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate = WattsVisionClimate(coordinator, mock_thermostat_device) + + assert climate._device == mock_thermostat_device + assert climate._attr_unique_id == "thermostat_123" + + device_info = climate.device_info + assert device_info is not None + assert device_info["identifiers"] == {("watts", "thermostat_123")} + assert device_info["name"] == "Test Thermostat" + assert device_info["manufacturer"] == "Watts" + assert device_info["model"] == "Vision+ thermostat" + + assert climate._attr_min_temp == 5.0 + assert climate._attr_max_temp == 30.0 + assert climate._attr_temperature_unit == UnitOfTemperature.CELSIUS + + +def test_current_temperature(mock_thermostat_device) -> None: + """Test current temperature property.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.current_temperature == 20.5 + + +def test_current_temperature_device_not_found(mock_thermostat_device) -> None: + """Test current temperature when device is not found.""" + coordinator = create_coordinator() + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.current_temperature is None + + +def test_target_temperature(mock_thermostat_device) -> None: + """Test target temperature property.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.target_temperature == 22.0 + + +def test_target_temperature_device_not_found(mock_thermostat_device) -> None: + """Test target temperature when device is not found.""" + coordinator = create_coordinator() + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.target_temperature is None + + +def test_hvac_mode_comfort(mock_thermostat_device) -> None: + """Test HVAC mode property for Comfort mode.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode == HVACMode.HEAT + + +def test_hvac_mode_eco(mock_thermostat_device) -> None: + """Test HVAC mode mapping for Eco mode.""" + mock_thermostat_device.thermostat_mode = "Eco" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode == HVACMode.HEAT + + +def test_hvac_mode_program(mock_thermostat_device) -> None: + """Test HVAC mode mapping for Program mode.""" + mock_thermostat_device.thermostat_mode = "Program" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode == HVACMode.AUTO + + +def test_hvac_mode_off(mock_thermostat_device) -> None: + """Test HVAC mode mapping for Off mode.""" + mock_thermostat_device.thermostat_mode = "Off" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode == HVACMode.OFF + + +def test_hvac_mode_unknown(mock_thermostat_device) -> None: + """Test HVAC mode mapping for unknown mode.""" + mock_thermostat_device.thermostat_mode = "Unknown" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode is None + + +def test_hvac_mode_device_not_found(mock_thermostat_device) -> None: + """Test HVAC mode when device is not found.""" + coordinator = create_coordinator() + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.hvac_mode is None + + +def test_extra_state_attributes(mock_thermostat_device) -> None: + """Test extra state attributes.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + attrs = climate_entity.extra_state_attributes + assert attrs["thermostat_mode"] == "Comfort" + assert attrs["device_type"] == "thermostat" + assert attrs["room_name"] == "Living Room" + assert attrs["temperature_unit"] == "C" + assert attrs["available_thermostat_modes"] == [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer", + ] + + +def test_extra_state_attributes_device_not_found(mock_thermostat_device) -> None: + """Test extra state attributes when device is not found.""" + coordinator = create_coordinator() + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + attrs = climate_entity.extra_state_attributes + assert attrs == {} + + +def test_available_true(mock_thermostat_device) -> None: + """Test available property when device is online.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.available is True + + +def test_available_false_offline(mock_thermostat_device) -> None: + """Test available property when device is offline.""" + mock_thermostat_device.is_online = False + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.available is False + + +def test_available_false_device_not_found(mock_thermostat_device) -> None: + """Test available property when device is not found.""" + coordinator = create_coordinator() + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + assert climate_entity.available is False + + +async def test_set_temperature_success(mock_thermostat_device) -> None: + """Test temperature setting success.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + initial_temp = climate_entity.target_temperature + assert initial_temp == 22.0 + + await climate_entity.async_set_temperature(temperature=23.5) + coordinator.client.set_thermostat_temperature.assert_called_once_with( + climate_entity._device_id, 23.5 + ) + + coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + + +async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> None: + """Test temperature setting using ATTR_TEMPERATURE.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_temperature(**{ATTR_TEMPERATURE: 24.0}) + coordinator.client.set_thermostat_temperature.assert_called_once_with( + climate_entity._device_id, 24.0 + ) + + coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + + +async def test_set_temperature_no_temperature(mock_thermostat_device) -> None: + """Test temperature setting without temperature parameter.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_temperature() + coordinator.client.set_thermostat_temperature.assert_not_called() + coordinator.async_refresh_device.assert_not_called() + + +async def test_set_temperature_error(mock_thermostat_device) -> None: + """Test temperature setting with API error.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( + "API Error" + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_temperature(temperature=23.5) + + coordinator.client.set_thermostat_temperature.assert_called_once_with( + climate_entity._device_id, 23.5 + ) + coordinator.async_refresh_device.assert_not_called() + + +async def test_set_temperature_changes_setpoint(mock_thermostat_device) -> None: + """Test that setting temperature actually changes the device setpoint.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + assert climate_entity.target_temperature == 22.0 + + async def mock_refresh_device(device_id): + mock_thermostat_device.setpoint = 25.0 + + coordinator.async_refresh_device.side_effect = mock_refresh_device + + await climate_entity.async_set_temperature(temperature=25.0) + + coordinator.client.set_thermostat_temperature.assert_called_once_with( + climate_entity._device_id, 25.0 + ) + + coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + + assert climate_entity.target_temperature == 25.0 + + +async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) -> None: + """Test that temperature doesn't change when API call fails.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( + "API Error" + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + initial_temp = climate_entity.target_temperature + assert initial_temp == 22.0 + + await climate_entity.async_set_temperature(temperature=25.0) + + coordinator.client.set_thermostat_temperature.assert_called_once_with( + climate_entity._device_id, 25.0 + ) + + coordinator.async_refresh_device.assert_not_called() + + assert climate_entity.target_temperature == initial_temp + + +async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: + """Test HVAC mode setting to heat.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_hvac_mode(HVACMode.HEAT) + coordinator.client.set_thermostat_mode.assert_called_once_with( + climate_entity._device_id, ThermostatMode.COMFORT + ) + + +async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: + """Test HVAC mode setting to off.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_hvac_mode(HVACMode.OFF) + coordinator.client.set_thermostat_mode.assert_called_once_with( + climate_entity._device_id, ThermostatMode.OFF + ) + + +async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: + """Test HVAC mode setting to auto.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_hvac_mode(HVACMode.AUTO) + coordinator.client.set_thermostat_mode.assert_called_once_with( + climate_entity._device_id, ThermostatMode.PROGRAM + ) + + +async def test_set_hvac_mode_unsupported(mock_thermostat_device) -> None: + """Test setting unsupported HVAC mode.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_hvac_mode(HVACMode.FAN_ONLY) + coordinator.client.set_thermostat_mode.assert_not_called() + + +async def test_set_hvac_mode_error(mock_thermostat_device) -> None: + """Test HVAC mode setting with API error.""" + coordinator = create_coordinator( + {mock_thermostat_device.device_id: mock_thermostat_device} + ) + coordinator.client.set_thermostat_mode.side_effect = RuntimeError("API Error") + climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + + await climate_entity.async_set_hvac_mode(HVACMode.HEAT) + + coordinator.client.set_thermostat_mode.assert_called_once_with( + climate_entity._device_id, ThermostatMode.COMFORT + ) + + +async def test_async_setup_entry_with_thermostat_devices( + mock_hass, mock_config_entry +) -> None: + """Test setup entry with thermostat devices.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + thermostat_device = MagicMock(spec=ThermostatDevice) + thermostat_device.device_id = "thermostat_1" + thermostat_device.device_name = "Test Thermostat 1" + thermostat_device.current_temperature = 21.0 + thermostat_device.setpoint = 23.0 + thermostat_device.thermostat_mode = "Program" + thermostat_device.min_allowed_temperature = 5.0 + thermostat_device.max_allowed_temperature = 30.0 + thermostat_device.temperature_unit = "C" + thermostat_device.is_online = True + thermostat_device.device_type = "thermostat" + thermostat_device.room_name = "Bedroom" + thermostat_device.available_thermostat_modes = ["Program", "Eco", "Comfort", "Off"] + + coordinator.data = {"thermostat_1": thermostat_device} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 1 + assert isinstance(entities[0], WattsVisionClimate) + assert args[1]["update_before_add"] is True + + +async def test_async_setup_entry_no_thermostat_devices( + mock_hass, mock_config_entry, mock_switch_device +) -> None: + """Test setup entry with no thermostat devices (only switch devices).""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + coordinator.data = {"switch_1": mock_switch_device} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_not_called() + + +async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> None: + """Test setup entry with empty coordinator data.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + coordinator.data = {} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_not_called() + + +async def test_async_setup_entry_multiple_thermostat_devices( + mock_hass, mock_config_entry +) -> None: + """Test setup entry with multiple thermostat devices.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + thermostat1 = MagicMock(spec=ThermostatDevice) + thermostat1.device_id = "thermostat_1" + thermostat1.device_name = "Thermostat 1" + + thermostat2 = MagicMock(spec=ThermostatDevice) + thermostat2.device_id = "thermostat_2" + thermostat2.device_name = "Thermostat 2" + + coordinator.data = {"thermostat_1": thermostat1, "thermostat_2": thermostat2} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 2 + assert all(isinstance(entity, WattsVisionClimate) for entity in entities) + assert args[1]["update_before_add"] is True + + +async def test_async_setup_entry_mixed_devices( + mock_hass, mock_config_entry, mock_switch_device +) -> None: + """Test setup entry with mixed device types.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + thermostat_device = MagicMock(spec=ThermostatDevice) + thermostat_device.device_id = "thermostat_1" + thermostat_device.device_name = "Test Thermostat" + + coordinator.data = { + "thermostat_1": thermostat_device, + "switch_1": mock_switch_device, + } + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + with patch( + "homeassistant.helpers.entity_platform.AddEntitiesCallback", new=AsyncMock + ): + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 1 + assert isinstance(entities[0], WattsVisionClimate) + assert args[1]["update_before_add"] is True diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py new file mode 100644 index 00000000000000..9741d527f5b0ea --- /dev/null +++ b/tests/components/watts/test_config_flow.py @@ -0,0 +1,343 @@ +"""Test the Watts Vision config flow.""" + +from unittest.mock import AsyncMock, 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.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +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["type"] is FlowResultType.EXTERNAL_STEP + assert "url" in result + assert OAUTH2_AUTHORIZE in result["url"] + assert "response_type=code" in result["url"] + assert "scope=" in result["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", + ), + patch( + "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh", + return_value=AsyncMock(), + ), + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Watts Vision +" + assert "token" in result2["data"] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@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, + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.ABORT + assert result2["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"}, + ) + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.ABORT + assert result2["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()) + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.ABORT + assert result2["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") + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.ABORT + assert result2["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_entry = config_entries.ConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Watts Vision +", + data={"token": {"refresh_token": "mock-refresh-token"}}, + source=config_entries.SOURCE_USER, + unique_id="watts_vision_user123", + entry_id="test_entry", + options={}, + discovery_keys={}, + subentries_data={}, + ) + await hass.config_entries.async_add(mock_entry) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + if result["type"] is FlowResultType.EXTERNAL_STEP: + 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, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_unique_config_entry_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that a full flow after an existing entry aborts due to uniqueness.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + 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", + ), + patch( + "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh", + return_value=AsyncMock(), + ), + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result3 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result3["type"] is FlowResultType.EXTERNAL_STEP + + state2 = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result3["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + resp2 = await client.get(f"/auth/external/callback?code=efgh&state={state2}") + assert resp2.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token-2", + "access_token": "mock-access-token-2", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) + assert result4["type"] is FlowResultType.ABORT + assert result4["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py new file mode 100644 index 00000000000000..f50a3266289a4d --- /dev/null +++ b/tests/components/watts/test_coordinator.py @@ -0,0 +1,164 @@ +"""Tests for the Watts Vision coordinator.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from visionpluspython.client import WattsVisionClient +from visionpluspython.models import Device + +from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +DOMAIN = "watts" +UPDATE_INTERVAL = 30 + + +@pytest.fixture +def mock_hass(): + """Mock HomeAssistant instance.""" + return MagicMock(spec=HomeAssistant) + + +@pytest.fixture +def mock_client(): + """Mock WattsVisionClient instance.""" + client = MagicMock(spec=WattsVisionClient) + client.discover_devices = AsyncMock() + client.get_device = AsyncMock() + client.get_devices_report = AsyncMock() + return client + + +@pytest.fixture +def mock_device(): + """Mock Watts Vision device.""" + device = MagicMock(spec=Device) + device.device_id = "device_123" + device.device_name = "Test Device" + return device + + +@pytest.fixture +def coordinator(mock_hass, mock_client): + """Create a WattsVisionCoordinator instance.""" + return WattsVisionCoordinator(mock_hass, mock_client) + + +async def test_coordinator_initialization(coordinator, mock_hass, mock_client) -> None: + """Test coordinator initialization.""" + assert coordinator.hass == mock_hass + assert coordinator.client == mock_client + assert coordinator.name == DOMAIN + assert coordinator.update_interval.total_seconds() == UPDATE_INTERVAL + assert coordinator._is_initialized is False + assert coordinator._devices == {} + + +async def test_async_config_entry_first_refresh_success( + coordinator, mock_client, mock_device +) -> None: + """Test successful initial device discovery.""" + mock_client.discover_devices.return_value = [mock_device] + + await coordinator.async_config_entry_first_refresh() + + mock_client.discover_devices.assert_called_once() + assert coordinator._is_initialized is True + assert coordinator._devices == {mock_device.device_id: mock_device} + + +async def test_async_config_entry_first_refresh_failure( + coordinator, mock_client +) -> None: + """Test failed initial device discovery.""" + mock_client.discover_devices.side_effect = ConnectionError("API error") + + try: + await coordinator.async_config_entry_first_refresh() + pytest.fail("Expected UpdateFailed to be raised") + except UpdateFailed: + pass + + assert coordinator._is_initialized is False + assert coordinator._devices == {} + + +async def test_async_refresh_device_success( + coordinator, mock_client, mock_device +) -> None: + """Test refreshing a specific device successfully.""" + coordinator._devices = {mock_device.device_id: mock_device} + coordinator._is_initialized = True + mock_client.get_device.return_value = mock_device + + await coordinator.async_refresh_device(mock_device.device_id) + + mock_client.get_device.assert_called_once_with(mock_device.device_id, refresh=True) + assert coordinator._devices[mock_device.device_id] == mock_device + + +async def test_async_refresh_device_failure( + coordinator, mock_client, mock_device +) -> None: + """Test refreshing a specific device when API call fails.""" + coordinator._devices = {mock_device.device_id: mock_device} + coordinator._is_initialized = True + mock_client.get_device.side_effect = ConnectionError("Refresh error") + + await coordinator.async_refresh_device(mock_device.device_id) + + mock_client.get_device.assert_called_once_with(mock_device.device_id, refresh=True) + assert coordinator._devices[mock_device.device_id] == mock_device # Unchanged + + +async def test_async_update_data_not_initialized( + coordinator, mock_client, mock_device +) -> None: + """Test _async_update_data when coordinator is not initialized.""" + mock_client.discover_devices.return_value = [mock_device] + + result = await coordinator._async_update_data() + + mock_client.discover_devices.assert_called_once() + assert coordinator._is_initialized is True + assert result == {mock_device.device_id: mock_device} + + +async def test_async_update_data_no_devices(coordinator, mock_client) -> None: + """Test _async_update_data when no devices are known.""" + coordinator._is_initialized = True + coordinator._devices = {} + + result = await coordinator._async_update_data() + + mock_client.get_devices_report.assert_not_called() + assert result == {} + + +async def test_async_update_data_success(coordinator, mock_client, mock_device) -> None: + """Test successful device report update.""" + coordinator._is_initialized = True + coordinator._devices = {mock_device.device_id: mock_device} + updated_device = MagicMock(spec=Device) + updated_device.device_id = mock_device.device_id + mock_client.get_devices_report.return_value = { + mock_device.device_id: updated_device + } + + result = await coordinator._async_update_data() + + mock_client.get_devices_report.assert_called_once_with([mock_device.device_id]) + assert coordinator._devices[mock_device.device_id] == updated_device + assert result == {mock_device.device_id: updated_device} + + +async def test_async_shutdown(coordinator, mock_device) -> None: + """Test coordinator shutdown.""" + coordinator._devices = {mock_device.device_id: mock_device} + coordinator._is_initialized = True + + await coordinator.async_shutdown() + + assert coordinator._devices == {} + assert coordinator._is_initialized is False diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py new file mode 100644 index 00000000000000..f2f5ef31105805 --- /dev/null +++ b/tests/components/watts/test_init.py @@ -0,0 +1,357 @@ +"""Test the Watts Vision integration initialization.""" + +from unittest.mock import AsyncMock, patch + +from aiohttp import ClientError, ClientResponseError +from visionpluspython import WattsVisionClient +from visionpluspython.auth import WattsVisionAuth + +from homeassistant.components.watts import async_unload_entry +from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + +DOMAIN = "watts" +TEST_DEVICE_ID = "test-device-id" +TEST_ACCESS_TOKEN = "test-access-token" +TEST_REFRESH_TOKEN = "test-refresh-token" +TEST_EXPIRES_AT = 9999999999 + + +async def test_setup_entry_success(hass: HomeAssistant) -> None: + """Test successful setup and unload of entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "device_id": TEST_DEVICE_ID, + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + }, + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + patch( + "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh" + ) as mock_first_refresh, + patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, + patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + mock_session_instance = AsyncMock() + mock_session_instance.token = { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 9999999999, + } + mock_session_instance.async_ensure_token_valid = AsyncMock() + mock_session.return_value = mock_session_instance + + mock_auth_instance = AsyncMock() + mock_auth_class.return_value = mock_auth_instance + + mock_client_instance = AsyncMock() + mock_client_class.return_value = mock_client_instance + + mock_first_refresh.return_value = None + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert config_entry.state is ConfigEntryState.LOADED + mock_first_refresh.assert_called_once() + + # Test unload + unload_result = await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert unload_result is True + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_auth_failed(hass: HomeAssistant) -> None: + """Test setup with authentication failure.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + }, + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + # Raise 401 error + mock_session_instance = AsyncMock() + mock_session_instance.async_ensure_token_valid.side_effect = ( + ClientResponseError(None, None, status=401, message="Unauthorized") + ) + mock_session_instance.token = { + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + } + mock_session.return_value = mock_session_instance + + 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 + + +async def test_setup_entry_not_ready(hass: HomeAssistant) -> None: + """Test setup when network is temporarily unavailable.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + }, + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + mock_session_instance = AsyncMock() + mock_session_instance.async_ensure_token_valid.side_effect = ClientError( + "Connection timeout" + ) + mock_session_instance.token = { + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + } + mock_session.return_value = mock_session_instance + + 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_coordinator_update_failed(hass: HomeAssistant) -> None: + """Test setup when coordinator update fails.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + }, + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, + patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, + patch( + "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh" + ) as mock_first_refresh, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + mock_session_instance = AsyncMock() + mock_session_instance.token = { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + } + mock_session_instance.async_ensure_token_valid = AsyncMock() + mock_session.return_value = mock_session_instance + + mock_auth_instance = AsyncMock() + mock_auth_class.return_value = mock_auth_instance + mock_client_instance = AsyncMock() + mock_client_class.return_value = mock_client_instance + + mock_first_refresh.side_effect = UpdateFailed("Coordinator update failed") + + 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_unload_entry_client_error(hass: HomeAssistant) -> None: + """Test unload when client close raises OSError.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + config_entry.add_to_hass(hass) + + # Mock the runtime data + mock_client = AsyncMock(spec=WattsVisionClient) + mock_client.close.side_effect = OSError("Connection error") + mock_auth = AsyncMock(spec=WattsVisionAuth) + mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + + config_entry.runtime_data = { + "client": mock_client, + "auth": mock_auth, + "coordinator": mock_coordinator, + } + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ): + result = await async_unload_entry(hass, config_entry) + + assert result is True + mock_client.close.assert_called_once() + mock_auth.close.assert_called_once() + + +async def test_unload_entry_auth_error(hass: HomeAssistant) -> None: + """Test unload when auth close raises OSError.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + config_entry.add_to_hass(hass) + + # Mock the runtime data + mock_client = AsyncMock(spec=WattsVisionClient) + mock_auth = AsyncMock(spec=WattsVisionAuth) + mock_auth.close.side_effect = OSError("Auth error") + mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + + config_entry.runtime_data = { + "client": mock_client, + "auth": mock_auth, + "coordinator": mock_coordinator, + } + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ): + result = await async_unload_entry(hass, config_entry) + + assert result is True + mock_client.close.assert_called_once() + mock_auth.close.assert_called_once() + + +async def test_unload_entry_coordinator_error(hass: HomeAssistant) -> None: + """Test unload when coordinator close raises OSError.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + config_entry.add_to_hass(hass) + + # Mock the runtime data + mock_client = AsyncMock(spec=WattsVisionClient) + mock_auth = AsyncMock(spec=WattsVisionAuth) + mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + mock_coordinator.async_shutdown.side_effect = OSError("Coordinator error") + + config_entry.runtime_data = { + "client": mock_client, + "auth": mock_auth, + "coordinator": mock_coordinator, + } + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ): + result = await async_unload_entry(hass, config_entry) + + assert result is True + mock_client.close.assert_called_once() + mock_auth.close.assert_called_once() + + +async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: + """Test unload when platform unload fails.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + config_entry.add_to_hass(hass) + + # Mock the runtime data + mock_client = AsyncMock(spec=WattsVisionClient) + mock_auth = AsyncMock(spec=WattsVisionAuth) + mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + + config_entry.runtime_data = { + "client": mock_client, + "auth": mock_auth, + "coordinator": mock_coordinator, + } + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + result = await async_unload_entry(hass, config_entry) + + assert result is False + mock_client.close.assert_called_once() + mock_auth.close.assert_called_once() diff --git a/tests/components/watts/test_switch.py b/tests/components/watts/test_switch.py new file mode 100644 index 00000000000000..c6e5bf9cb926cb --- /dev/null +++ b/tests/components/watts/test_switch.py @@ -0,0 +1,351 @@ +"""Tests for the Watts Vision switch platform.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from visionpluspython.models import SwitchDevice, ThermostatDevice + +from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.components.watts.switch import WattsVisionSwitch, async_setup_entry +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import MockConfigEntry + + +def create_coordinator(devices=None): + """Create a mock coordinator.""" + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.data = devices or {} + coordinator.client = MagicMock() + coordinator.client.set_switch_state = AsyncMock() + coordinator.async_refresh_device = AsyncMock() + coordinator.last_update_success = True + return coordinator + + +@pytest.fixture +def mock_hass(): + """Mock HomeAssistant instance.""" + return MagicMock(spec=HomeAssistant) + + +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry.""" + return MockConfigEntry(domain="watts") + + +@pytest.fixture +def mock_switch_device(): + """Mock Watts Vision switch device.""" + device = MagicMock(spec=SwitchDevice) + device.device_id = "switch_123" + device.device_name = "Test Switch" + device.is_turned_on = True + device.is_online = True + device.device_type = "switch" + device.room_name = "Bedroom" + return device + + +@pytest.fixture +def mock_thermostat_device(): + """Mock Watts Vision thermostat device.""" + device = MagicMock(spec=ThermostatDevice) + device.device_id = "thermostat_123" + device.device_name = "Test Thermostat" + device.current_temperature = 20.0 + device.setpoint = 22.0 + device.thermostat_mode = "Comfort" + device.min_allowed_temperature = 5.0 + device.max_allowed_temperature = 30.0 + device.temperature_unit = "C" + device.is_online = True + device.device_type = "thermostat" + device.room_name = "Kitchen" + device.available_thermostat_modes = ["Program", "Eco", "Comfort", "Off"] + return device + + +async def test_switch_initialization(mock_switch_device) -> None: + """Test switch entity initialization.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch = WattsVisionSwitch(coordinator, mock_switch_device) + + assert switch._device == mock_switch_device + assert switch._attr_unique_id == "switch_123" + + device_info = switch.device_info + assert device_info is not None + assert device_info["identifiers"] == {("watts", "switch_123")} + assert device_info["name"] == "Test Switch" + assert device_info["manufacturer"] == "Watts" + assert device_info["model"] == "Vision+ switch" + + +async def test_switch_is_on_true(mock_switch_device) -> None: + """Test is_on property when switch is on.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.is_on is True + + +async def test_switch_is_on_false(mock_switch_device) -> None: + """Test is_on property when switch is off.""" + mock_switch_device.is_turned_on = False + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.is_on is False + + +async def test_switch_is_on_device_not_found(mock_switch_device) -> None: + """Test is_on property when device is not found.""" + coordinator = create_coordinator() + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.is_on is None + + +async def test_switch_extra_state_attributes(mock_switch_device) -> None: + """Test extra state attributes.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + attrs = switch_entity.extra_state_attributes + assert attrs["device_type"] == "switch" + assert attrs["room_name"] == "Bedroom" + + +async def test_switch_extra_state_attributes_device_not_found( + mock_switch_device, +) -> None: + """Test extra state attributes when device is not found.""" + coordinator = create_coordinator() + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + attrs = switch_entity.extra_state_attributes + assert attrs == {} + + +async def test_switch_available_true(mock_switch_device) -> None: + """Test available property when device is online.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.available is True + + +async def test_switch_available_false_offline(mock_switch_device) -> None: + """Test available property when device is offline.""" + mock_switch_device.is_online = False + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.available is False + + +async def test_switch_available_false_device_not_found(mock_switch_device) -> None: + """Test available property when device is not found.""" + coordinator = create_coordinator() + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + assert switch_entity.available is False + + +async def test_switch_turn_on_success(mock_switch_device) -> None: + """Test switch turn on success.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_on() + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, True + ) + + +async def test_switch_turn_off_success(mock_switch_device) -> None: + """Test switch turn off success.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_off() + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, False + ) + + +async def test_switch_turn_on_error(mock_switch_device) -> None: + """Test turn on with error handling.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + coordinator.client.set_switch_state.side_effect = RuntimeError("API Error") + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_on() + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, True + ) + + +async def test_switch_turn_off_error(mock_switch_device) -> None: + """Test turn off with error handling.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + coordinator.client.set_switch_state.side_effect = RuntimeError("API Error") + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_off() + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, False + ) + + +async def test_switch_turn_on_refresh_success(mock_switch_device) -> None: + """Test switch turn on with refresh after delay.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_on() + + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, True + ) + + coordinator.async_refresh_device.assert_called_once_with(switch_entity._device_id) + + +async def test_switch_turn_off_refresh_success(mock_switch_device) -> None: + """Test switch turn off with refresh after delay.""" + coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) + switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) + + await switch_entity.async_turn_off() + + coordinator.client.set_switch_state.assert_called_once_with( + switch_entity._device_id, False + ) + + coordinator.async_refresh_device.assert_called_once_with(switch_entity._device_id) + + +async def test_async_setup_entry_with_switch_devices( + mock_hass, mock_config_entry +) -> None: + """Test setup entry with switch devices.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + switch_device = MagicMock(spec=SwitchDevice) + switch_device.device_id = "switch_1" + switch_device.device_name = "Test Switch 1" + switch_device.is_turned_on = True + switch_device.is_online = True + switch_device.device_type = "switch" + switch_device.room_name = "Living Room" + + coordinator.data = {"switch_1": switch_device} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 1 + assert isinstance(entities[0], WattsVisionSwitch) + assert args[1]["update_before_add"] is True + + +async def test_async_setup_entry_no_switch_devices( + mock_hass, mock_config_entry, mock_thermostat_device +) -> None: + """Test setup entry with no switch devices (only thermostat devices).""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + coordinator.data = {"thermostat_1": mock_thermostat_device} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_not_called() + + +async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> None: + """Test setup entry with empty coordinator data.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + coordinator.data = {} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_not_called() + + +async def test_async_setup_entry_multiple_switch_devices( + mock_hass, mock_config_entry +) -> None: + """Test setup entry with multiple switch devices.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + switch1 = MagicMock(spec=SwitchDevice) + switch1.device_id = "switch_1" + switch1.device_name = "Switch 1" + + switch2 = MagicMock(spec=SwitchDevice) + switch2.device_id = "switch_2" + switch2.device_name = "Switch 2" + + coordinator.data = {"switch_1": switch1, "switch_2": switch2} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 2 + assert all(isinstance(entity, WattsVisionSwitch) for entity in entities) + assert args[1]["update_before_add"] is True + + +async def test_async_setup_entry_mixed_devices( + mock_hass, mock_config_entry, mock_thermostat_device +) -> None: + """Test setup entry with mixed device types.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + coordinator = MagicMock(spec=WattsVisionCoordinator) + coordinator.last_update_success = True + + switch_device = MagicMock(spec=SwitchDevice) + switch_device.device_id = "switch_1" + switch_device.device_name = "Test Switch" + + coordinator.data = { + "switch_1": switch_device, + "thermostat_1": mock_thermostat_device, + } + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = {"coordinator": coordinator} + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once() + args = async_add_entities.call_args + entities = args[0][0] + assert len(entities) == 1 + assert isinstance(entities[0], WattsVisionSwitch) + assert args[1]["update_before_add"] is True From d296bbe8a877629d43dfb5817b783be8d5a5d0ef Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Fri, 26 Sep 2025 09:24:17 +0000 Subject: [PATCH 02/43] Remove switch platform --- homeassistant/components/watts/__init__.py | 6 +- homeassistant/components/watts/climate.py | 3 +- homeassistant/components/watts/config_flow.py | 3 +- homeassistant/components/watts/const.py | 3 +- homeassistant/components/watts/coordinator.py | 5 +- homeassistant/components/watts/switch.py | 96 ----- tests/components/watts/test_switch.py | 351 ------------------ 7 files changed, 12 insertions(+), 455 deletions(-) delete mode 100644 homeassistant/components/watts/switch.py delete mode 100644 tests/components/watts/test_switch.py diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 7487a7c05d604c..542ebc35953931 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -7,6 +7,8 @@ from typing import TypedDict from aiohttp import ClientError, ClientResponseError +from visionpluspython.auth import WattsVisionAuth +from visionpluspython.client import WattsVisionClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -14,14 +16,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers.update_coordinator import UpdateFailed -from visionpluspython.auth import WattsVisionAuth -from visionpluspython.client import WattsVisionClient from .coordinator import WattsVisionCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE] class WattsVisionRuntimeData(TypedDict): diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index a237fc49d18301..8e8936f408c3e2 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -6,6 +6,8 @@ import logging from typing import Any +from visionpluspython.models import ThermostatDevice + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -14,7 +16,6 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from visionpluspython.models import ThermostatDevice from . import WattsVisionConfigEntry from .const import ( diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index 28f8cf3d6c7bac..2a0a925f5a8c42 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -3,9 +3,10 @@ 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 visionpluspython.auth import WattsVisionAuth from .const import DOMAIN, OAUTH2_SCOPES diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index 45f594dc2a8ea7..f96a5b6a917cf6 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -1,8 +1,9 @@ """Constants for the Watts Vision+ integration.""" -from homeassistant.components.climate import HVACMode 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" diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 804f60c32ae03e..ab872cf5e4a24a 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -5,11 +5,12 @@ from datetime import timedelta import logging -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from visionpluspython.client import WattsVisionClient from visionpluspython.models import Device +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + from .const import DOMAIN, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watts/switch.py b/homeassistant/components/watts/switch.py deleted file mode 100644 index 5405eda64f25d2..00000000000000 --- a/homeassistant/components/watts/switch.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Switch platform for Watts Vision integration.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from visionpluspython.models import SwitchDevice - -from . import WattsVisionConfigEntry -from .const import UPDATE_DELAY_AFTER_COMMAND -from .coordinator import WattsVisionCoordinator -from .entity import WattsVisionEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: WattsVisionConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Watts Vision switch entities from a config entry.""" - - coordinator: WattsVisionCoordinator = entry.runtime_data["coordinator"] - - entities = [] - for device in coordinator.data.values(): - if isinstance(device, SwitchDevice): - entities.append(WattsVisionSwitch(coordinator, device)) - _LOGGER.debug("Created switch entity for device %s", device.device_id) - - if entities: - async_add_entities(entities, update_before_add=True) - _LOGGER.info("Added %d switch entities", len(entities)) - - -class WattsVisionSwitch(WattsVisionEntity, SwitchEntity): - """Watts Vision switch device as a switch entity.""" - - def __init__( - self, - coordinator: WattsVisionCoordinator, - device: SwitchDevice, - ) -> None: - """Initialize the switch entity.""" - - super().__init__(coordinator, device.device_id) - self._device = device - self._device_id = device.device_id - self._attr_name = None - - @property - def is_on(self) -> bool | None: - """Return True if the switch is on.""" - device = self.coordinator.data.get(self._device_id) - if isinstance(device, SwitchDevice): - return device.is_turned_on - return None - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional state attributes.""" - device = self.coordinator.data.get(self._device_id) - if not isinstance(device, SwitchDevice): - return {} - - return {"device_type": device.device_type, "room_name": device.room_name} - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - try: - await self.coordinator.client.set_switch_state(self._device_id, True) - _LOGGER.debug("Successfully turned on switch %s", self._attr_name) - - await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self._device_id) - - except RuntimeError as err: - _LOGGER.error("Error turning on switch %s: %s", self._attr_name, err) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - try: - await self.coordinator.client.set_switch_state(self._device_id, False) - _LOGGER.debug("Successfully turned off switch %s", self._attr_name) - - await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self._device_id) - - except RuntimeError as err: - _LOGGER.error("Error turning off switch %s: %s", self._attr_name, err) diff --git a/tests/components/watts/test_switch.py b/tests/components/watts/test_switch.py deleted file mode 100644 index c6e5bf9cb926cb..00000000000000 --- a/tests/components/watts/test_switch.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Tests for the Watts Vision switch platform.""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from visionpluspython.models import SwitchDevice, ThermostatDevice - -from homeassistant.components.watts.coordinator import WattsVisionCoordinator -from homeassistant.components.watts.switch import WattsVisionSwitch, async_setup_entry -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from tests.common import MockConfigEntry - - -def create_coordinator(devices=None): - """Create a mock coordinator.""" - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.data = devices or {} - coordinator.client = MagicMock() - coordinator.client.set_switch_state = AsyncMock() - coordinator.async_refresh_device = AsyncMock() - coordinator.last_update_success = True - return coordinator - - -@pytest.fixture -def mock_hass(): - """Mock HomeAssistant instance.""" - return MagicMock(spec=HomeAssistant) - - -@pytest.fixture -def mock_config_entry(): - """Create a mock config entry.""" - return MockConfigEntry(domain="watts") - - -@pytest.fixture -def mock_switch_device(): - """Mock Watts Vision switch device.""" - device = MagicMock(spec=SwitchDevice) - device.device_id = "switch_123" - device.device_name = "Test Switch" - device.is_turned_on = True - device.is_online = True - device.device_type = "switch" - device.room_name = "Bedroom" - return device - - -@pytest.fixture -def mock_thermostat_device(): - """Mock Watts Vision thermostat device.""" - device = MagicMock(spec=ThermostatDevice) - device.device_id = "thermostat_123" - device.device_name = "Test Thermostat" - device.current_temperature = 20.0 - device.setpoint = 22.0 - device.thermostat_mode = "Comfort" - device.min_allowed_temperature = 5.0 - device.max_allowed_temperature = 30.0 - device.temperature_unit = "C" - device.is_online = True - device.device_type = "thermostat" - device.room_name = "Kitchen" - device.available_thermostat_modes = ["Program", "Eco", "Comfort", "Off"] - return device - - -async def test_switch_initialization(mock_switch_device) -> None: - """Test switch entity initialization.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch = WattsVisionSwitch(coordinator, mock_switch_device) - - assert switch._device == mock_switch_device - assert switch._attr_unique_id == "switch_123" - - device_info = switch.device_info - assert device_info is not None - assert device_info["identifiers"] == {("watts", "switch_123")} - assert device_info["name"] == "Test Switch" - assert device_info["manufacturer"] == "Watts" - assert device_info["model"] == "Vision+ switch" - - -async def test_switch_is_on_true(mock_switch_device) -> None: - """Test is_on property when switch is on.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.is_on is True - - -async def test_switch_is_on_false(mock_switch_device) -> None: - """Test is_on property when switch is off.""" - mock_switch_device.is_turned_on = False - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.is_on is False - - -async def test_switch_is_on_device_not_found(mock_switch_device) -> None: - """Test is_on property when device is not found.""" - coordinator = create_coordinator() - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.is_on is None - - -async def test_switch_extra_state_attributes(mock_switch_device) -> None: - """Test extra state attributes.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - attrs = switch_entity.extra_state_attributes - assert attrs["device_type"] == "switch" - assert attrs["room_name"] == "Bedroom" - - -async def test_switch_extra_state_attributes_device_not_found( - mock_switch_device, -) -> None: - """Test extra state attributes when device is not found.""" - coordinator = create_coordinator() - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - attrs = switch_entity.extra_state_attributes - assert attrs == {} - - -async def test_switch_available_true(mock_switch_device) -> None: - """Test available property when device is online.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.available is True - - -async def test_switch_available_false_offline(mock_switch_device) -> None: - """Test available property when device is offline.""" - mock_switch_device.is_online = False - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.available is False - - -async def test_switch_available_false_device_not_found(mock_switch_device) -> None: - """Test available property when device is not found.""" - coordinator = create_coordinator() - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - assert switch_entity.available is False - - -async def test_switch_turn_on_success(mock_switch_device) -> None: - """Test switch turn on success.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_on() - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, True - ) - - -async def test_switch_turn_off_success(mock_switch_device) -> None: - """Test switch turn off success.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_off() - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, False - ) - - -async def test_switch_turn_on_error(mock_switch_device) -> None: - """Test turn on with error handling.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - coordinator.client.set_switch_state.side_effect = RuntimeError("API Error") - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_on() - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, True - ) - - -async def test_switch_turn_off_error(mock_switch_device) -> None: - """Test turn off with error handling.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - coordinator.client.set_switch_state.side_effect = RuntimeError("API Error") - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_off() - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, False - ) - - -async def test_switch_turn_on_refresh_success(mock_switch_device) -> None: - """Test switch turn on with refresh after delay.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_on() - - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, True - ) - - coordinator.async_refresh_device.assert_called_once_with(switch_entity._device_id) - - -async def test_switch_turn_off_refresh_success(mock_switch_device) -> None: - """Test switch turn off with refresh after delay.""" - coordinator = create_coordinator({mock_switch_device.device_id: mock_switch_device}) - switch_entity = WattsVisionSwitch(coordinator, mock_switch_device) - - await switch_entity.async_turn_off() - - coordinator.client.set_switch_state.assert_called_once_with( - switch_entity._device_id, False - ) - - coordinator.async_refresh_device.assert_called_once_with(switch_entity._device_id) - - -async def test_async_setup_entry_with_switch_devices( - mock_hass, mock_config_entry -) -> None: - """Test setup entry with switch devices.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - - switch_device = MagicMock(spec=SwitchDevice) - switch_device.device_id = "switch_1" - switch_device.device_name = "Test Switch 1" - switch_device.is_turned_on = True - switch_device.is_online = True - switch_device.device_type = "switch" - switch_device.room_name = "Living Room" - - coordinator.data = {"switch_1": switch_device} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 1 - assert isinstance(entities[0], WattsVisionSwitch) - assert args[1]["update_before_add"] is True - - -async def test_async_setup_entry_no_switch_devices( - mock_hass, mock_config_entry, mock_thermostat_device -) -> None: - """Test setup entry with no switch devices (only thermostat devices).""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - coordinator.data = {"thermostat_1": mock_thermostat_device} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_not_called() - - -async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> None: - """Test setup entry with empty coordinator data.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - coordinator.data = {} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_not_called() - - -async def test_async_setup_entry_multiple_switch_devices( - mock_hass, mock_config_entry -) -> None: - """Test setup entry with multiple switch devices.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - - switch1 = MagicMock(spec=SwitchDevice) - switch1.device_id = "switch_1" - switch1.device_name = "Switch 1" - - switch2 = MagicMock(spec=SwitchDevice) - switch2.device_id = "switch_2" - switch2.device_name = "Switch 2" - - coordinator.data = {"switch_1": switch1, "switch_2": switch2} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 2 - assert all(isinstance(entity, WattsVisionSwitch) for entity in entities) - assert args[1]["update_before_add"] is True - - -async def test_async_setup_entry_mixed_devices( - mock_hass, mock_config_entry, mock_thermostat_device -) -> None: - """Test setup entry with mixed device types.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - - switch_device = MagicMock(spec=SwitchDevice) - switch_device.device_id = "switch_1" - switch_device.device_name = "Test Switch" - - coordinator.data = { - "switch_1": switch_device, - "thermostat_1": mock_thermostat_device, - } - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 1 - assert isinstance(entities[0], WattsVisionSwitch) - assert args[1]["update_before_add"] is True From 58e58434574dd7477f7b435052d73819d5d7e4d1 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Mon, 29 Sep 2025 08:46:40 +0000 Subject: [PATCH 03/43] Use dataclass instead of Dict Update DataUpdateCoordinator to use explicit config_entry parameter --- homeassistant/components/watts/__init__.py | 35 ++----- homeassistant/components/watts/climate.py | 2 +- homeassistant/components/watts/coordinator.py | 6 +- homeassistant/components/watts/manifest.json | 2 +- .../generated/application_credentials.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/watts/test_climate.py | 31 ++++++- tests/components/watts/test_coordinator.py | 11 ++- tests/components/watts/test_init.py | 91 +++---------------- 10 files changed, 63 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 542ebc35953931..31e9b7e71fa265 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations +from dataclasses import dataclass from http import HTTPStatus import logging -from typing import TypedDict from aiohttp import ClientError, ClientResponseError from visionpluspython.auth import WattsVisionAuth @@ -24,7 +24,8 @@ PLATFORMS: list[Platform] = [Platform.CLIMATE] -class WattsVisionRuntimeData(TypedDict): +@dataclass +class WattsVisionRuntimeData: """Runtime data for Watts Vision integration.""" auth: WattsVisionAuth @@ -57,13 +58,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) 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=aiohttp_client.async_get_clientsession(hass), + session=session, ) client = WattsVisionClient(auth) - coordinator = WattsVisionCoordinator(hass, client) + coordinator = WattsVisionCoordinator(hass, client, entry) try: await coordinator.async_config_entry_first_refresh() @@ -87,31 +89,6 @@ async def async_unload_entry( """Unload a config entry.""" _LOGGER.debug("Unloading Watts Vision + integration") - runtime_data = entry.runtime_data - - client = runtime_data["client"] - if client: - try: - await client.close() - _LOGGER.debug("Client closed successfully") - except OSError as err: - _LOGGER.warning("Error closing client: %s", err) - - auth = runtime_data["auth"] - if auth: - try: - await auth.close() - _LOGGER.debug("Auth closed successfully") - except OSError as err: - _LOGGER.warning("Error closing auth: %s", err) - - coordinator = runtime_data["coordinator"] - if coordinator: - try: - await coordinator.async_shutdown() - _LOGGER.debug("Coordinator closed successfully") - except (OSError, AttributeError) as err: - _LOGGER.warning("Error closing coordinator: %s", err) unload_result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 8e8936f408c3e2..b989b30455bcf9 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -36,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up Watts Vision climate entities from a config entry.""" - coordinator: WattsVisionCoordinator = entry.runtime_data["coordinator"] + coordinator: WattsVisionCoordinator = entry.runtime_data.coordinator entities = [] for device in coordinator.data.values(): diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index ab872cf5e4a24a..de9a905bf6eb30 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -8,6 +8,7 @@ from visionpluspython.client import WattsVisionClient from visionpluspython.models import Device +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,13 +20,16 @@ class WattsVisionCoordinator(DataUpdateCoordinator): """Class to fetch Watts Vision+ data.""" - def __init__(self, hass: HomeAssistant, client: WattsVisionClient) -> None: + def __init__( + self, hass: HomeAssistant, client: WattsVisionClient, config_entry: ConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), + config_entry=config_entry, ) self.client = client self._devices: dict[str, Device] = {} diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index d615f9f5f6ded7..69e391bedb74c9 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["visionpluspython==1.0.0"], + "requirements": ["visionpluspython==1.0.1"], "ssdp": [], "zeroconf": [] } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 525681b358435c..c292928e14cb41 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -38,8 +38,8 @@ "spotify", "tesla_fleet", "twitch", - "watts", "volvo", + "watts", "weheat", "withings", "xbox", diff --git a/requirements_all.txt b/requirements_all.txt index c31548fb20a0ff..1ec6f2245364a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3095,7 +3095,7 @@ victron-vrm==0.1.7 vilfo-api-client==0.5.0 # homeassistant.components.watts -visionpluspython==1.0.0 +visionpluspython==1.0.1 # homeassistant.components.voip voip-utils==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3617d035713a6a..0755486b3e12d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2566,7 +2566,7 @@ victron-vrm==0.1.7 vilfo-api-client==0.5.0 # homeassistant.components.watts -visionpluspython==1.0.0 +visionpluspython==1.0.1 # homeassistant.components.voip voip-utils==0.3.4 diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index f09c23205926e5..8d14c6f3421ae1 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -6,6 +6,7 @@ from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode from homeassistant.components.climate import HVACMode +from homeassistant.components.watts import WattsVisionRuntimeData from homeassistant.components.watts.climate import WattsVisionClimate, async_setup_entry from homeassistant.components.watts.coordinator import WattsVisionCoordinator from homeassistant.config_entries import ConfigEntry @@ -447,7 +448,11 @@ async def test_async_setup_entry_with_thermostat_devices( coordinator.data = {"thermostat_1": thermostat_device} entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} + entry.runtime_data = WattsVisionRuntimeData( + coordinator=coordinator, + auth=MagicMock(), + client=MagicMock(), + ) await async_setup_entry(mock_hass, entry, async_add_entities) @@ -470,7 +475,11 @@ async def test_async_setup_entry_no_thermostat_devices( coordinator.data = {"switch_1": mock_switch_device} entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} + entry.runtime_data = WattsVisionRuntimeData( + coordinator=coordinator, + auth=MagicMock(), + client=MagicMock(), + ) await async_setup_entry(mock_hass, entry, async_add_entities) @@ -486,7 +495,11 @@ async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> Non coordinator.data = {} entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} + entry.runtime_data = WattsVisionRuntimeData( + coordinator=coordinator, + auth=MagicMock(), + client=MagicMock(), + ) await async_setup_entry(mock_hass, entry, async_add_entities) @@ -513,7 +526,11 @@ async def test_async_setup_entry_multiple_thermostat_devices( coordinator.data = {"thermostat_1": thermostat1, "thermostat_2": thermostat2} entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} + entry.runtime_data = WattsVisionRuntimeData( + coordinator=coordinator, + auth=MagicMock(), + client=MagicMock(), + ) await async_setup_entry(mock_hass, entry, async_add_entities) @@ -544,7 +561,11 @@ async def test_async_setup_entry_mixed_devices( } entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = {"coordinator": coordinator} + entry.runtime_data = WattsVisionRuntimeData( + coordinator=coordinator, + auth=MagicMock(), + client=MagicMock(), + ) with patch( "homeassistant.helpers.entity_platform.AddEntitiesCallback", new=AsyncMock diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py index f50a3266289a4d..8a08c9145f71c8 100644 --- a/tests/components/watts/test_coordinator.py +++ b/tests/components/watts/test_coordinator.py @@ -7,6 +7,7 @@ from visionpluspython.models import Device from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -20,6 +21,12 @@ def mock_hass(): return MagicMock(spec=HomeAssistant) +@pytest.fixture +def mock_config_entry(): + """Mock ConfigEntry instance.""" + return MagicMock(spec=ConfigEntry) + + @pytest.fixture def mock_client(): """Mock WattsVisionClient instance.""" @@ -40,9 +47,9 @@ def mock_device(): @pytest.fixture -def coordinator(mock_hass, mock_client): +def coordinator(mock_hass, mock_client, mock_config_entry): """Create a WattsVisionCoordinator instance.""" - return WattsVisionCoordinator(mock_hass, mock_client) + return WattsVisionCoordinator(mock_hass, mock_client, mock_config_entry) async def test_coordinator_initialization(coordinator, mock_hass, mock_client) -> None: diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index f2f5ef31105805..5d719b7c6c41e1 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -6,7 +6,7 @@ from visionpluspython import WattsVisionClient from visionpluspython.auth import WattsVisionAuth -from homeassistant.components.watts import async_unload_entry +from homeassistant.components.watts import WattsVisionRuntimeData, async_unload_entry from homeassistant.components.watts.coordinator import WattsVisionCoordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -234,8 +234,8 @@ async def test_setup_entry_coordinator_update_failed(hass: HomeAssistant) -> Non assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry_client_error(hass: HomeAssistant) -> None: - """Test unload when client close raises OSError.""" +async def test_unload_entry_success(hass: HomeAssistant) -> None: + """Test successful unload of entry.""" config_entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -244,77 +244,14 @@ async def test_unload_entry_client_error(hass: HomeAssistant) -> None: # Mock the runtime data mock_client = AsyncMock(spec=WattsVisionClient) - mock_client.close.side_effect = OSError("Connection error") mock_auth = AsyncMock(spec=WattsVisionAuth) mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) - config_entry.runtime_data = { - "client": mock_client, - "auth": mock_auth, - "coordinator": mock_coordinator, - } - - with patch( - "homeassistant.config_entries.ConfigEntries.async_unload_platforms", - return_value=True, - ): - result = await async_unload_entry(hass, config_entry) - - assert result is True - mock_client.close.assert_called_once() - mock_auth.close.assert_called_once() - - -async def test_unload_entry_auth_error(hass: HomeAssistant) -> None: - """Test unload when auth close raises OSError.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - ) - config_entry.add_to_hass(hass) - - # Mock the runtime data - mock_client = AsyncMock(spec=WattsVisionClient) - mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_auth.close.side_effect = OSError("Auth error") - mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) - - config_entry.runtime_data = { - "client": mock_client, - "auth": mock_auth, - "coordinator": mock_coordinator, - } - - with patch( - "homeassistant.config_entries.ConfigEntries.async_unload_platforms", - return_value=True, - ): - result = await async_unload_entry(hass, config_entry) - - assert result is True - mock_client.close.assert_called_once() - mock_auth.close.assert_called_once() - - -async def test_unload_entry_coordinator_error(hass: HomeAssistant) -> None: - """Test unload when coordinator close raises OSError.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, + config_entry.runtime_data = WattsVisionRuntimeData( + client=mock_client, + auth=mock_auth, + coordinator=mock_coordinator, ) - config_entry.add_to_hass(hass) - - # Mock the runtime data - mock_client = AsyncMock(spec=WattsVisionClient) - mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) - mock_coordinator.async_shutdown.side_effect = OSError("Coordinator error") - - config_entry.runtime_data = { - "client": mock_client, - "auth": mock_auth, - "coordinator": mock_coordinator, - } with patch( "homeassistant.config_entries.ConfigEntries.async_unload_platforms", @@ -323,8 +260,6 @@ async def test_unload_entry_coordinator_error(hass: HomeAssistant) -> None: result = await async_unload_entry(hass, config_entry) assert result is True - mock_client.close.assert_called_once() - mock_auth.close.assert_called_once() async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: @@ -340,11 +275,11 @@ async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: mock_auth = AsyncMock(spec=WattsVisionAuth) mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) - config_entry.runtime_data = { - "client": mock_client, - "auth": mock_auth, - "coordinator": mock_coordinator, - } + config_entry.runtime_data = WattsVisionRuntimeData( + client=mock_client, + auth=mock_auth, + coordinator=mock_coordinator, + ) with patch( "homeassistant.config_entries.ConfigEntries.async_unload_platforms", @@ -353,5 +288,3 @@ async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: result = await async_unload_entry(hass, config_entry) assert result is False - mock_client.close.assert_called_once() - mock_auth.close.assert_called_once() From ea2cc123e5067c9d87b633584defd61e69d7a4a8 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:14:23 +0200 Subject: [PATCH 04/43] Update tests/components/watts/test_coordinator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/watts/test_coordinator.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py index 8a08c9145f71c8..0cef925d7d24c8 100644 --- a/tests/components/watts/test_coordinator.py +++ b/tests/components/watts/test_coordinator.py @@ -81,11 +81,8 @@ async def test_async_config_entry_first_refresh_failure( """Test failed initial device discovery.""" mock_client.discover_devices.side_effect = ConnectionError("API error") - try: + with pytest.raises(UpdateFailed): await coordinator.async_config_entry_first_refresh() - pytest.fail("Expected UpdateFailed to be raised") - except UpdateFailed: - pass assert coordinator._is_initialized is False assert coordinator._devices == {} From bc4d3bd2a45d4e26e47af3fcd94c1713ebd61abe Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Tue, 7 Oct 2025 14:08:56 +0000 Subject: [PATCH 05/43] Simplify HA integration, update tests --- homeassistant/components/watts/__init__.py | 18 +-- homeassistant/components/watts/auth.py | 25 ---- homeassistant/components/watts/climate.py | 69 ++++----- homeassistant/components/watts/config_flow.py | 6 +- homeassistant/components/watts/coordinator.py | 8 +- homeassistant/components/watts/entity.py | 41 +++--- homeassistant/components/watts/manifest.json | 4 +- tests/components/watts/test_auth.py | 39 ------ tests/components/watts/test_climate.py | 131 +++--------------- tests/components/watts/test_config_flow.py | 2 +- tests/components/watts/test_coordinator.py | 11 -- tests/components/watts/test_init.py | 2 +- 12 files changed, 76 insertions(+), 280 deletions(-) delete mode 100644 homeassistant/components/watts/auth.py delete mode 100644 tests/components/watts/test_auth.py diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 31e9b7e71fa265..3c5ab1aeb819c5 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow -from homeassistant.helpers.update_coordinator import UpdateFailed from .coordinator import WattsVisionCoordinator @@ -67,10 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) client = WattsVisionClient(auth) coordinator = WattsVisionCoordinator(hass, client, entry) - try: - await coordinator.async_config_entry_first_refresh() - except UpdateFailed as err: - raise ConfigEntryNotReady("Failed to fetch initial data") from err + await coordinator.async_config_entry_first_refresh() entry.runtime_data = WattsVisionRuntimeData( auth=auth, @@ -87,14 +83,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: WattsVisionConfigEntry ) -> bool: """Unload a config entry.""" - - _LOGGER.debug("Unloading Watts Vision + integration") - - unload_result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if not unload_result: - _LOGGER.error("Failed to unload platforms for Watts Vision + integration") - else: - _LOGGER.debug("Successfully unloaded platforms for Watts Vision + integration") - - return unload_result + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watts/auth.py b/homeassistant/components/watts/auth.py deleted file mode 100644 index 7a3ec82c985890..00000000000000 --- a/homeassistant/components/watts/auth.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Authentication for Watts Vision+ using OAuth2 config entry.""" - -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - - -class ConfigEntryAuth: - """Provide Watts Vision+ authentication with OAuth2 config entry.""" - - def __init__( - self, - hass: HomeAssistant, - oauth_session: config_entry_oauth2_flow.OAuth2Session, - ) -> None: - """Initialize Watts Vision+ Auth.""" - - self.hass = hass - self.session = oauth_session - self.token = self.session.token - - async def refresh_tokens(self) -> str: - """Refresh and return new Watts Vision+ tokens.""" - - await self.session.async_ensure_token_valid() - return self.session.token["access_token"] diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index b989b30455bcf9..1b0991fbb70b77 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -36,17 +36,15 @@ async def async_setup_entry( ) -> None: """Set up Watts Vision climate entities from a config entry.""" - coordinator: WattsVisionCoordinator = entry.runtime_data.coordinator + coordinator = entry.runtime_data.coordinator - entities = [] - for device in coordinator.data.values(): - if isinstance(device, ThermostatDevice): - entities.append(WattsVisionClimate(coordinator, device)) - _LOGGER.debug("Created climate entity for device %s", device.device_id) - - if entities: - async_add_entities(entities, update_before_add=True) - _LOGGER.info("Added %d climate entities", len(entities)) + async_add_entities( + [ + WattsVisionClimate(coordinator, device) + for device in coordinator.data.values() + ], + update_before_add=True, + ) class WattsVisionClimate(WattsVisionEntity, ClimateEntity): @@ -54,6 +52,7 @@ class WattsVisionClimate(WattsVisionEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_name = None def __init__( self, @@ -64,9 +63,6 @@ def __init__( super().__init__(coordinator, device.device_id) self._device = device - self._device_id = device.device_id - - self._attr_name = None self._attr_min_temp = device.min_allowed_temperature self._attr_max_temp = device.max_allowed_temperature @@ -76,44 +72,37 @@ def __init__( else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + @property + def thermostat_device(self) -> ThermostatDevice | None: + """Return the device as a ThermostatDevice if it's the correct type.""" + if self.device and isinstance(self.device, ThermostatDevice): + return self.device + return None + @property def current_temperature(self) -> float | None: """Return the current temperature.""" - device = self.coordinator.data.get(self._device_id) - if isinstance(device, ThermostatDevice): - return device.current_temperature + if self.thermostat_device: + return self.thermostat_device.current_temperature return None @property def target_temperature(self) -> float | None: """Return the temperature setpoint.""" - device = self.coordinator.data.get(self._device_id) - if isinstance(device, ThermostatDevice): - return device.setpoint + if self.thermostat_device: + return self.thermostat_device.setpoint return None @property def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" - device = self.coordinator.data.get(self._device_id) - if isinstance(device, ThermostatDevice): - return THERMOSTAT_MODE_TO_HVAC.get(device.thermostat_mode) + if self.thermostat_device: + return THERMOSTAT_MODE_TO_HVAC.get(self.thermostat_device.thermostat_mode) return None - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional state attributes.""" - device = self.coordinator.data.get(self._device_id) - if not isinstance(device, ThermostatDevice): - return {} - - return { - "thermostat_mode": device.thermostat_mode, - "device_type": device.device_type, - "room_name": device.room_name, - "temperature_unit": device.temperature_unit, - "available_thermostat_modes": device.available_thermostat_modes, - } + async def async_request_refresh(self) -> None: + """Request refresh for this specific entity only.""" + await self.coordinator.async_refresh_device(self.device_id) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -123,7 +112,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: try: await self.coordinator.client.set_thermostat_temperature( - self._device_id, temperature + self.device_id, temperature ) _LOGGER.debug( "Successfully set temperature to %s for %s", @@ -132,7 +121,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ) await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self._device_id) + await self.coordinator.async_refresh_device(self.device_id) except RuntimeError as err: _LOGGER.error("Error setting temperature for %s: %s", self._attr_name, err) @@ -146,7 +135,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: return try: - await self.coordinator.client.set_thermostat_mode(self._device_id, mode) + await self.coordinator.client.set_thermostat_mode(self.device_id, mode) _LOGGER.debug( "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", hvac_mode, @@ -155,7 +144,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self._device_id) + await self.coordinator.async_refresh_device(self.device_id) except (ValueError, RuntimeError) as err: _LOGGER.error("Error setting HVAC mode for %s: %s", self._attr_name, err) diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index 2a0a925f5a8c42..c71e67528aa2a2 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -32,10 +32,6 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } - async def async_step_user(self, user_input=None) -> ConfigFlowResult: - """Handle a flow initiated by the user.""" - return await super().async_step_user(user_input) - async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -45,7 +41,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu if not user_id: return self.async_abort(reason="invalid_token") - await self.async_set_unique_id(f"watts_vision_{user_id}") + await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index de9a905bf6eb30..91f64a0a69ceae 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -class WattsVisionCoordinator(DataUpdateCoordinator): +class WattsVisionCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Class to fetch Watts Vision+ data.""" def __init__( @@ -87,12 +87,6 @@ async def _async_update_data(self) -> dict[str, Device]: else: return self._devices - async def async_shutdown(self) -> None: - """Shutdown the coordinator and cleanup resources.""" - self._devices.clear() - self._is_initialized = False - _LOGGER.debug("Coordinator resources cleaned up") - @property def device_ids(self) -> list[str]: """Get list of all device IDs.""" diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index 4a22c521c2a543..bcdeb5ee57871c 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from visionpluspython.models import Device + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,36 +19,29 @@ class WattsVisionEntity(CoordinatorEntity[WattsVisionCoordinator]): def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, context=device_id) self.device_id = device_id self._attr_unique_id = device_id - device = coordinator.data.get(device_id) - if device: - self._attr_name = getattr(device, "device_name", None) - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - - device = self.coordinator.data.get(self.device_id) - if device: - return DeviceInfo( + if self.device: + self._attr_name = getattr(self.device, "device_name", None) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device_id)}, - name=device.device_name, + name=self.device.device_name, manufacturer="Watts", - model=f"Vision+ {device.device_type}", + model=f"Vision+ {self.device.device_type}", ) - return None + + @property + def device(self) -> Device | None: + """Return the device object from the coordinator data.""" + return self.coordinator.data.get(self.coordinator_context) @property def available(self) -> bool: """Return True if entity is available.""" - - if not self.coordinator.last_update_success: - return False - - device = self.coordinator.data.get(self.device_id) - if device: - return device.is_online - return False + return ( + super().available + and self.device_id in self.coordinator.data + and self.coordinator.data[self.device_id].is_online + ) diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 69e391bedb74c9..018573c77a2fbc 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -7,7 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["visionpluspython==1.0.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["visionpluspython==1.0.1"] } diff --git a/tests/components/watts/test_auth.py b/tests/components/watts/test_auth.py deleted file mode 100644 index 71d4914b71c300..00000000000000 --- a/tests/components/watts/test_auth.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Test authentication for Watts Vision+ integration.""" - -from unittest.mock import AsyncMock, MagicMock - -from homeassistant.components.watts.auth import ConfigEntryAuth -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - - -async def test_config_entry_auth_initialization(hass: HomeAssistant) -> None: - """Test ConfigEntryAuth initialization.""" - mock_session = MagicMock(spec=config_entry_oauth2_flow.OAuth2Session) - mock_session.token = { - "access_token": "test_token", - "refresh_token": "refresh_token", - } - - auth = ConfigEntryAuth(hass, mock_session) - - assert auth.hass == hass - assert auth.session == mock_session - assert auth.token == mock_session.token - - -async def test_refresh_tokens(hass: HomeAssistant) -> None: - """Test token refresh.""" - mock_session = MagicMock(spec=config_entry_oauth2_flow.OAuth2Session) - mock_session.token = { - "access_token": "new_access_token", - "refresh_token": "refresh_token", - } - mock_session.async_ensure_token_valid = AsyncMock() - - auth = ConfigEntryAuth(hass, mock_session) - - result = await auth.refresh_tokens() - - assert result == "new_access_token" - mock_session.async_ensure_token_valid.assert_called_once() diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 8d14c6f3421ae1..b4e23a8b7a581e 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -1,9 +1,9 @@ """Tests for the Watts Vision climate platform.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest -from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode +from visionpluspython.models import ThermostatDevice, ThermostatMode from homeassistant.components.climate import HVACMode from homeassistant.components.watts import WattsVisionRuntimeData @@ -67,19 +67,6 @@ def mock_thermostat_device(): return device -@pytest.fixture -def mock_switch_device(): - """Mock Watts Vision switch device.""" - device = MagicMock(spec=SwitchDevice) - device.device_id = "switch_123" - device.device_name = "Test Switch" - device.is_turned_on = True - device.is_online = True - device.device_type = "switch" - device.room_name = "Kitchen" - return device - - async def test_climate_initialization(mock_thermostat_device) -> None: """Test climate entity initialization.""" coordinator = create_coordinator( @@ -190,33 +177,18 @@ def test_hvac_mode_device_not_found(mock_thermostat_device) -> None: assert climate_entity.hvac_mode is None -def test_extra_state_attributes(mock_thermostat_device) -> None: - """Test extra state attributes.""" +async def test_async_request_refresh(mock_thermostat_device) -> None: + """Test async_request_refresh method.""" coordinator = create_coordinator( {mock_thermostat_device.device_id: mock_thermostat_device} ) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - attrs = climate_entity.extra_state_attributes - assert attrs["thermostat_mode"] == "Comfort" - assert attrs["device_type"] == "thermostat" - assert attrs["room_name"] == "Living Room" - assert attrs["temperature_unit"] == "C" - assert attrs["available_thermostat_modes"] == [ - "Program", - "Eco", - "Comfort", - "Off", - "Defrost", - "Timer", - ] + await climate_entity.async_request_refresh() -def test_extra_state_attributes_device_not_found(mock_thermostat_device) -> None: - """Test extra state attributes when device is not found.""" - coordinator = create_coordinator() - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - attrs = climate_entity.extra_state_attributes - assert attrs == {} + coordinator.async_refresh_device.assert_called_once_with( + mock_thermostat_device.device_id + ) def test_available_true(mock_thermostat_device) -> None: @@ -257,10 +229,10 @@ async def test_set_temperature_success(mock_thermostat_device) -> None: await climate_entity.async_set_temperature(temperature=23.5) coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity._device_id, 23.5 + climate_entity.device_id, 23.5 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> None: @@ -272,10 +244,10 @@ async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> await climate_entity.async_set_temperature(**{ATTR_TEMPERATURE: 24.0}) coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity._device_id, 24.0 + climate_entity.device_id, 24.0 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) async def test_set_temperature_no_temperature(mock_thermostat_device) -> None: @@ -303,7 +275,7 @@ async def test_set_temperature_error(mock_thermostat_device) -> None: await climate_entity.async_set_temperature(temperature=23.5) coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity._device_id, 23.5 + climate_entity.device_id, 23.5 ) coordinator.async_refresh_device.assert_not_called() @@ -325,10 +297,10 @@ async def mock_refresh_device(device_id): await climate_entity.async_set_temperature(temperature=25.0) coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity._device_id, 25.0 + climate_entity.device_id, 25.0 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity._device_id) + coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) assert climate_entity.target_temperature == 25.0 @@ -349,7 +321,7 @@ async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) await climate_entity.async_set_temperature(temperature=25.0) coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity._device_id, 25.0 + climate_entity.device_id, 25.0 ) coordinator.async_refresh_device.assert_not_called() @@ -366,7 +338,7 @@ async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: await climate_entity.async_set_hvac_mode(HVACMode.HEAT) coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity._device_id, ThermostatMode.COMFORT + climate_entity.device_id, ThermostatMode.COMFORT ) @@ -379,7 +351,7 @@ async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: await climate_entity.async_set_hvac_mode(HVACMode.OFF) coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity._device_id, ThermostatMode.OFF + climate_entity.device_id, ThermostatMode.OFF ) @@ -392,7 +364,7 @@ async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: await climate_entity.async_set_hvac_mode(HVACMode.AUTO) coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity._device_id, ThermostatMode.PROGRAM + climate_entity.device_id, ThermostatMode.PROGRAM ) @@ -418,7 +390,7 @@ async def test_set_hvac_mode_error(mock_thermostat_device) -> None: await climate_entity.async_set_hvac_mode(HVACMode.HEAT) coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity._device_id, ThermostatMode.COMFORT + climate_entity.device_id, ThermostatMode.COMFORT ) @@ -464,28 +436,6 @@ async def test_async_setup_entry_with_thermostat_devices( assert args[1]["update_before_add"] is True -async def test_async_setup_entry_no_thermostat_devices( - mock_hass, mock_config_entry, mock_switch_device -) -> None: - """Test setup entry with no thermostat devices (only switch devices).""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - coordinator.data = {"switch_1": mock_switch_device} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - coordinator=coordinator, - auth=MagicMock(), - client=MagicMock(), - ) - - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_not_called() - - async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> None: """Test setup entry with empty coordinator data.""" async_add_entities = MagicMock(spec=AddEntitiesCallback) @@ -503,7 +453,8 @@ async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> Non await async_setup_entry(mock_hass, entry, async_add_entities) - async_add_entities.assert_not_called() + # With empty data, async_add_entities is called with empty list + async_add_entities.assert_called_once_with([], update_before_add=True) async def test_async_setup_entry_multiple_thermostat_devices( @@ -540,41 +491,3 @@ async def test_async_setup_entry_multiple_thermostat_devices( assert len(entities) == 2 assert all(isinstance(entity, WattsVisionClimate) for entity in entities) assert args[1]["update_before_add"] is True - - -async def test_async_setup_entry_mixed_devices( - mock_hass, mock_config_entry, mock_switch_device -) -> None: - """Test setup entry with mixed device types.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - - thermostat_device = MagicMock(spec=ThermostatDevice) - thermostat_device.device_id = "thermostat_1" - thermostat_device.device_name = "Test Thermostat" - - coordinator.data = { - "thermostat_1": thermostat_device, - "switch_1": mock_switch_device, - } - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - coordinator=coordinator, - auth=MagicMock(), - client=MagicMock(), - ) - - with patch( - "homeassistant.helpers.entity_platform.AddEntitiesCallback", new=AsyncMock - ): - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 1 - assert isinstance(entities[0], WattsVisionClimate) - assert args[1]["update_before_add"] is True diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 9741d527f5b0ea..47cb87b18e4ac3 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -214,7 +214,7 @@ async def test_unique_config_entry( title="Watts Vision +", data={"token": {"refresh_token": "mock-refresh-token"}}, source=config_entries.SOURCE_USER, - unique_id="watts_vision_user123", + unique_id="user123", entry_id="test_entry", options={}, discovery_keys={}, diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py index 0cef925d7d24c8..7f36f28536b03e 100644 --- a/tests/components/watts/test_coordinator.py +++ b/tests/components/watts/test_coordinator.py @@ -155,14 +155,3 @@ async def test_async_update_data_success(coordinator, mock_client, mock_device) mock_client.get_devices_report.assert_called_once_with([mock_device.device_id]) assert coordinator._devices[mock_device.device_id] == updated_device assert result == {mock_device.device_id: updated_device} - - -async def test_async_shutdown(coordinator, mock_device) -> None: - """Test coordinator shutdown.""" - coordinator._devices = {mock_device.device_id: mock_device} - coordinator._is_initialized = True - - await coordinator.async_shutdown() - - assert coordinator._devices == {} - assert coordinator._is_initialized is False diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index 5d719b7c6c41e1..37f7a7556acd6f 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -231,7 +231,7 @@ async def test_setup_entry_coordinator_update_failed(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert result is False - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.SETUP_ERROR async def test_unload_entry_success(hass: HomeAssistant) -> None: From f1f76aa27041d27296d97f5f675d628150e44904 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Tue, 7 Oct 2025 14:34:24 +0000 Subject: [PATCH 06/43] Use HA http session Add room name in device name for easier setup --- homeassistant/components/watts/__init__.py | 2 +- homeassistant/components/watts/entity.py | 7 +++++-- tests/components/watts/test_climate.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 3c5ab1aeb819c5..025c82f6845f63 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) session=session, ) - client = WattsVisionClient(auth) + client = WattsVisionClient(auth, session) coordinator = WattsVisionCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index bcdeb5ee57871c..fce1ed220b04be 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -24,10 +24,13 @@ def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: self._attr_unique_id = device_id if self.device: - self._attr_name = getattr(self.device, "device_name", None) + device_name = self.device.device_name + if hasattr(self.device, "room_name") and self.device.room_name: + device_name = f"{self.device.room_name} {device_name}" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device_id)}, - name=self.device.device_name, + name=device_name, manufacturer="Watts", model=f"Vision+ {self.device.device_type}", ) diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index b4e23a8b7a581e..24f544fd2c8bc7 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -80,7 +80,7 @@ async def test_climate_initialization(mock_thermostat_device) -> None: device_info = climate.device_info assert device_info is not None assert device_info["identifiers"] == {("watts", "thermostat_123")} - assert device_info["name"] == "Test Thermostat" + assert device_info["name"] == "Living Room Test Thermostat" assert device_info["manufacturer"] == "Watts" assert device_info["model"] == "Vision+ thermostat" From 5571d2ac8c391de4b1ff4614fa65f8f10bce85b0 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 8 Oct 2025 07:02:50 +0000 Subject: [PATCH 07/43] Do not check instance type of thermostat, update tests --- homeassistant/components/watts/climate.py | 19 +++------------ homeassistant/components/watts/entity.py | 4 ++-- tests/components/watts/test_climate.py | 28 ----------------------- 3 files changed, 5 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 1b0991fbb70b77..c813feae46d914 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -72,33 +72,20 @@ def __init__( else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - @property - def thermostat_device(self) -> ThermostatDevice | None: - """Return the device as a ThermostatDevice if it's the correct type.""" - if self.device and isinstance(self.device, ThermostatDevice): - return self.device - return None - @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self.thermostat_device: - return self.thermostat_device.current_temperature - return None + return self.device.current_temperature @property def target_temperature(self) -> float | None: """Return the temperature setpoint.""" - if self.thermostat_device: - return self.thermostat_device.setpoint - return None + return self.device.setpoint @property def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" - if self.thermostat_device: - return THERMOSTAT_MODE_TO_HVAC.get(self.thermostat_device.thermostat_mode) - return None + return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode) async def async_request_refresh(self) -> None: """Request refresh for this specific entity only.""" diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index fce1ed220b04be..534e192e6c8bfb 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -36,9 +36,9 @@ def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: ) @property - def device(self) -> Device | None: + def device(self) -> Device: """Return the device object from the coordinator data.""" - return self.coordinator.data.get(self.coordinator_context) + return self.coordinator.data[self.coordinator_context] @property def available(self) -> bool: diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 24f544fd2c8bc7..683c9ad2bf4a51 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -98,13 +98,6 @@ def test_current_temperature(mock_thermostat_device) -> None: assert climate_entity.current_temperature == 20.5 -def test_current_temperature_device_not_found(mock_thermostat_device) -> None: - """Test current temperature when device is not found.""" - coordinator = create_coordinator() - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.current_temperature is None - - def test_target_temperature(mock_thermostat_device) -> None: """Test target temperature property.""" coordinator = create_coordinator( @@ -114,13 +107,6 @@ def test_target_temperature(mock_thermostat_device) -> None: assert climate_entity.target_temperature == 22.0 -def test_target_temperature_device_not_found(mock_thermostat_device) -> None: - """Test target temperature when device is not found.""" - coordinator = create_coordinator() - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.target_temperature is None - - def test_hvac_mode_comfort(mock_thermostat_device) -> None: """Test HVAC mode property for Comfort mode.""" coordinator = create_coordinator( @@ -170,13 +156,6 @@ def test_hvac_mode_unknown(mock_thermostat_device) -> None: assert climate_entity.hvac_mode is None -def test_hvac_mode_device_not_found(mock_thermostat_device) -> None: - """Test HVAC mode when device is not found.""" - coordinator = create_coordinator() - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode is None - - async def test_async_request_refresh(mock_thermostat_device) -> None: """Test async_request_refresh method.""" coordinator = create_coordinator( @@ -210,13 +189,6 @@ def test_available_false_offline(mock_thermostat_device) -> None: assert climate_entity.available is False -def test_available_false_device_not_found(mock_thermostat_device) -> None: - """Test available property when device is not found.""" - coordinator = create_coordinator() - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.available is False - - async def test_set_temperature_success(mock_thermostat_device) -> None: """Test temperature setting success.""" coordinator = create_coordinator( From dbd75c41f06adf192103f0dd2ae0bce80ef2fc05 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 8 Oct 2025 08:02:10 +0000 Subject: [PATCH 08/43] Use device id instead of name --- homeassistant/components/watts/climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index c813feae46d914..aec75a1e571686 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -104,21 +104,21 @@ async def async_set_temperature(self, **kwargs: Any) -> None: _LOGGER.debug( "Successfully set temperature to %s for %s", temperature, - self._attr_name, + self.device_id, ) await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) await self.coordinator.async_refresh_device(self.device_id) except RuntimeError as err: - _LOGGER.error("Error setting temperature for %s: %s", self._attr_name, err) + _LOGGER.error("Error setting temperature for %s: %s", self.device_id, err) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" mode = HVAC_MODE_TO_THERMOSTAT.get(hvac_mode) if mode is None: - _LOGGER.error("Unsupported HVAC mode %s for %s", hvac_mode, self._attr_name) + _LOGGER.error("Unsupported HVAC mode %s for %s", hvac_mode, self.device_id) return try: @@ -127,11 +127,11 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", hvac_mode, mode.name, - self._attr_name, + self.device_id, ) await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) await self.coordinator.async_refresh_device(self.device_id) except (ValueError, RuntimeError) as err: - _LOGGER.error("Error setting HVAC mode for %s: %s", self._attr_name, err) + _LOGGER.error("Error setting HVAC mode for %s: %s", self.device_id, err) From 49d007e7843adcdced4c704529450f04c01b8982 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:22:11 +0200 Subject: [PATCH 09/43] Update homeassistant/components/watts/climate.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/climate.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index aec75a1e571686..dae30e3099fd83 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -129,9 +129,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: mode.name, self.device_id, ) - - await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self.device_id) - except (ValueError, RuntimeError) as err: _LOGGER.error("Error setting HVAC mode for %s: %s", self.device_id, err) + + await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + await self.coordinator.async_refresh_device(self.device_id) From 71774844054ae0f410e988b5fc5e9b1532151369 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:22:33 +0200 Subject: [PATCH 10/43] Update homeassistant/components/watts/climate.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/climate.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index dae30e3099fd83..5e7b49e38fddf1 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -116,10 +116,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - mode = HVAC_MODE_TO_THERMOSTAT.get(hvac_mode) - if mode is None: - _LOGGER.error("Unsupported HVAC mode %s for %s", hvac_mode, self.device_id) - return + mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode] try: await self.coordinator.client.set_thermostat_mode(self.device_id, mode) From 03d1357e0ff1f1d56ce4952f57257b8ce9ec1ab0 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Fri, 10 Oct 2025 11:51:25 +0000 Subject: [PATCH 11/43] Use HomeAssistantError --- homeassistant/components/watts/climate.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 5e7b49e38fddf1..30f34c2291368a 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -15,6 +15,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WattsVisionConfigEntry @@ -111,7 +112,9 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self.coordinator.async_refresh_device(self.device_id) except RuntimeError as err: - _LOGGER.error("Error setting temperature for %s: %s", self.device_id, err) + raise HomeAssistantError( + f"Error setting temperature for {self.device_id}: {err}" + ) from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -127,7 +130,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self.device_id, ) except (ValueError, RuntimeError) as err: - _LOGGER.error("Error setting HVAC mode for %s: %s", self.device_id, err) + raise HomeAssistantError( + f"Error setting HVAC mode for {self.device_id}: {err}" + ) from err await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) await self.coordinator.async_refresh_device(self.device_id) From b431541b6af1418f12ce39cd545091432b864c17 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Tue, 14 Oct 2025 07:13:32 +0000 Subject: [PATCH 12/43] HubCoordinator for bulk update and device coordinator for individual updates --- homeassistant/components/watts/__init__.py | 20 +- homeassistant/components/watts/climate.py | 17 +- homeassistant/components/watts/coordinator.py | 57 +++- homeassistant/components/watts/entity.py | 16 +- tests/components/watts/test_climate.py | 183 ++++++------- tests/components/watts/test_config_flow.py | 4 +- tests/components/watts/test_coordinator.py | 256 +++++++++++------- tests/components/watts/test_init.py | 60 ++-- 8 files changed, 354 insertions(+), 259 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 025c82f6845f63..b290ceb8cd5827 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow -from .coordinator import WattsVisionCoordinator +from .coordinator import WattsVisionDeviceCoordinator, WattsVisionHubCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,8 @@ class WattsVisionRuntimeData: """Runtime data for Watts Vision integration.""" auth: WattsVisionAuth - coordinator: WattsVisionCoordinator + hub_coordinator: WattsVisionHubCoordinator + device_coordinators: dict[str, WattsVisionDeviceCoordinator] client: WattsVisionClient @@ -64,13 +65,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) ) client = WattsVisionClient(auth, session) - coordinator = WattsVisionCoordinator(hass, client, entry) + hub_coordinator = WattsVisionHubCoordinator(hass, client, entry) - await coordinator.async_config_entry_first_refresh() + await hub_coordinator.async_config_entry_first_refresh() + + device_coordinators = {} + for device_id in hub_coordinator.device_ids: + device_coordinator = WattsVisionDeviceCoordinator( + hass, client, entry, device_id + ) + device_coordinator.async_set_updated_data(hub_coordinator.data[device_id]) + device_coordinators[device_id] = device_coordinator entry.runtime_data = WattsVisionRuntimeData( auth=auth, - coordinator=coordinator, + hub_coordinator=hub_coordinator, + device_coordinators=device_coordinators, client=client, ) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 30f34c2291368a..2006de81ade58f 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -24,7 +24,7 @@ THERMOSTAT_MODE_TO_HVAC, UPDATE_DELAY_AFTER_COMMAND, ) -from .coordinator import WattsVisionCoordinator +from .coordinator import WattsVisionDeviceCoordinator from .entity import WattsVisionEntity _LOGGER = logging.getLogger(__name__) @@ -37,12 +37,13 @@ async def async_setup_entry( ) -> None: """Set up Watts Vision climate entities from a config entry.""" - coordinator = entry.runtime_data.coordinator + device_coordinators = entry.runtime_data.device_coordinators async_add_entities( [ - WattsVisionClimate(coordinator, device) - for device in coordinator.data.values() + WattsVisionClimate(device_coordinator, device_coordinator.data) + for device_coordinator in device_coordinators.values() + if device_coordinator.data ], update_before_add=True, ) @@ -57,7 +58,7 @@ class WattsVisionClimate(WattsVisionEntity, ClimateEntity): def __init__( self, - coordinator: WattsVisionCoordinator, + coordinator: WattsVisionDeviceCoordinator, device: ThermostatDevice, ) -> None: """Initialize the climate entity.""" @@ -90,7 +91,7 @@ def hvac_mode(self) -> HVACMode | None: async def async_request_refresh(self) -> None: """Request refresh for this specific entity only.""" - await self.coordinator.async_refresh_device(self.device_id) + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -109,7 +110,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ) await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self.device_id) + await self.coordinator.async_refresh() except RuntimeError as err: raise HomeAssistantError( @@ -135,4 +136,4 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) from err await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) - await self.coordinator.async_refresh_device(self.device_id) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 91f64a0a69ceae..c99819f54421a7 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -17,13 +17,13 @@ _LOGGER = logging.getLogger(__name__) -class WattsVisionCoordinator(DataUpdateCoordinator[dict[str, Device]]): - """Class to fetch Watts Vision+ data.""" +class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): + """Hub coordinator for bulk device discovery and updates.""" def __init__( self, hass: HomeAssistant, client: WattsVisionClient, config_entry: ConfigEntry ) -> None: - """Initialize the coordinator.""" + """Initialize the hub coordinator.""" super().__init__( hass, _LOGGER, @@ -51,19 +51,8 @@ async def _discover_devices(self) -> None: self._is_initialized = True _LOGGER.info("Initial discovery completed with %d devices", len(self._devices)) - async def async_refresh_device(self, device_id: str) -> None: - """Refresh a specific device.""" - try: - device = await self.client.get_device(device_id, refresh=True) - if device: - self._devices[device_id] = device - self.async_set_updated_data(self._devices) - _LOGGER.debug("Refreshed device %s", device_id) - except (ConnectionError, TimeoutError, ValueError) as err: - _LOGGER.error("Failed to refresh device %s: %s", device_id, err) - async def _async_update_data(self) -> dict[str, Device]: - """Fetch data from Watts Vision API.""" + """Fetch data from Watts Vision API for all devices.""" try: if not self._is_initialized: # First loading, discover devices @@ -91,3 +80,41 @@ async def _async_update_data(self) -> dict[str, Device]: def device_ids(self) -> list[str]: """Get list of all device IDs.""" return list(self._devices.keys()) + + +class WattsVisionDeviceCoordinator(DataUpdateCoordinator[Device | None]): + """Device coordinator for individual updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: ConfigEntry, + device_id: str, + ) -> None: + """Initialize the device 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 + + async def _async_update_data(self) -> Device | None: + """Refresh specific device.""" + try: + device = await self.client.get_device(self.device_id, refresh=True) + except (ConnectionError, TimeoutError, ValueError) as err: + _LOGGER.error("Failed to refresh device %s: %s", self.device_id, err) + raise UpdateFailed( + f"Failed to refresh device {self.device_id}: {err}" + ) from err + else: + if device: + _LOGGER.debug("Refreshed device %s", self.device_id) + return device + _LOGGER.warning("Device %s not found during refresh", self.device_id) + return None diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index 534e192e6c8bfb..3b746dc7b735f5 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -8,15 +8,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WattsVisionCoordinator +from .coordinator import WattsVisionDeviceCoordinator -class WattsVisionEntity(CoordinatorEntity[WattsVisionCoordinator]): +class WattsVisionEntity(CoordinatorEntity[WattsVisionDeviceCoordinator]): """Base entity for Watts Vision integration.""" _attr_has_entity_name = True - def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: + def __init__( + self, coordinator: WattsVisionDeviceCoordinator, device_id: str + ) -> None: """Initialize the entity.""" super().__init__(coordinator, context=device_id) @@ -38,13 +40,15 @@ def __init__(self, coordinator: WattsVisionCoordinator, device_id: str) -> None: @property def device(self) -> Device: """Return the device object from the coordinator data.""" - return self.coordinator.data[self.coordinator_context] + if self.coordinator.data is None: + raise RuntimeError("Empty device coordinator data") + return self.coordinator.data @property def available(self) -> bool: """Return True if entity is available.""" return ( super().available - and self.device_id in self.coordinator.data - and self.coordinator.data[self.device_id].is_online + and self.coordinator.data is not None + and self.coordinator.data.is_online ) diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 683c9ad2bf4a51..506e43502d1321 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -8,24 +8,26 @@ from homeassistant.components.climate import HVACMode from homeassistant.components.watts import WattsVisionRuntimeData from homeassistant.components.watts.climate import WattsVisionClimate, async_setup_entry -from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.components.watts.coordinator import WattsVisionDeviceCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import MockConfigEntry -def create_coordinator(devices=None): - """Create a mock coordinator.""" - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.data = devices or {} +def create_device_coordinator(device=None): + """Create a mock device coordinator.""" + coordinator = MagicMock(spec=WattsVisionDeviceCoordinator) + coordinator.data = device coordinator.client = MagicMock() coordinator.client.set_thermostat_temperature = AsyncMock() coordinator.client.set_thermostat_mode = AsyncMock() - coordinator.async_refresh_device = AsyncMock() + coordinator.async_refresh = AsyncMock() coordinator.last_update_success = True + coordinator.available = device is not None return coordinator @@ -69,9 +71,7 @@ def mock_thermostat_device(): async def test_climate_initialization(mock_thermostat_device) -> None: """Test climate entity initialization.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate._device == mock_thermostat_device @@ -91,27 +91,21 @@ async def test_climate_initialization(mock_thermostat_device) -> None: def test_current_temperature(mock_thermostat_device) -> None: """Test current temperature property.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.current_temperature == 20.5 def test_target_temperature(mock_thermostat_device) -> None: """Test target temperature property.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.target_temperature == 22.0 def test_hvac_mode_comfort(mock_thermostat_device) -> None: """Test HVAC mode property for Comfort mode.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.hvac_mode == HVACMode.HEAT @@ -119,9 +113,7 @@ def test_hvac_mode_comfort(mock_thermostat_device) -> None: def test_hvac_mode_eco(mock_thermostat_device) -> None: """Test HVAC mode mapping for Eco mode.""" mock_thermostat_device.thermostat_mode = "Eco" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.hvac_mode == HVACMode.HEAT @@ -129,9 +121,7 @@ def test_hvac_mode_eco(mock_thermostat_device) -> None: def test_hvac_mode_program(mock_thermostat_device) -> None: """Test HVAC mode mapping for Program mode.""" mock_thermostat_device.thermostat_mode = "Program" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.hvac_mode == HVACMode.AUTO @@ -139,9 +129,7 @@ def test_hvac_mode_program(mock_thermostat_device) -> None: def test_hvac_mode_off(mock_thermostat_device) -> None: """Test HVAC mode mapping for Off mode.""" mock_thermostat_device.thermostat_mode = "Off" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.hvac_mode == HVACMode.OFF @@ -149,32 +137,24 @@ def test_hvac_mode_off(mock_thermostat_device) -> None: def test_hvac_mode_unknown(mock_thermostat_device) -> None: """Test HVAC mode mapping for unknown mode.""" mock_thermostat_device.thermostat_mode = "Unknown" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.hvac_mode is None async def test_async_request_refresh(mock_thermostat_device) -> None: """Test async_request_refresh method.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_request_refresh() - coordinator.async_refresh_device.assert_called_once_with( - mock_thermostat_device.device_id - ) + coordinator.async_refresh.assert_called_once() def test_available_true(mock_thermostat_device) -> None: """Test available property when device is online.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.available is True @@ -182,18 +162,14 @@ def test_available_true(mock_thermostat_device) -> None: def test_available_false_offline(mock_thermostat_device) -> None: """Test available property when device is offline.""" mock_thermostat_device.is_online = False - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.available is False async def test_set_temperature_success(mock_thermostat_device) -> None: """Test temperature setting success.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) initial_temp = climate_entity.target_temperature @@ -204,14 +180,12 @@ async def test_set_temperature_success(mock_thermostat_device) -> None: climate_entity.device_id, 23.5 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) + coordinator.async_refresh.assert_called_once() async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> None: """Test temperature setting using ATTR_TEMPERATURE.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_set_temperature(**{ATTR_TEMPERATURE: 24.0}) @@ -219,52 +193,47 @@ async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> climate_entity.device_id, 24.0 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) + coordinator.async_refresh.assert_called_once() async def test_set_temperature_no_temperature(mock_thermostat_device) -> None: """Test temperature setting without temperature parameter.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_set_temperature() coordinator.client.set_thermostat_temperature.assert_not_called() - coordinator.async_refresh_device.assert_not_called() + coordinator.async_refresh.assert_not_called() async def test_set_temperature_error(mock_thermostat_device) -> None: """Test temperature setting with API error.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( "API Error" ) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - await climate_entity.async_set_temperature(temperature=23.5) + with pytest.raises(HomeAssistantError, match="Error setting temperature"): + await climate_entity.async_set_temperature(temperature=23.5) coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 23.5 ) - coordinator.async_refresh_device.assert_not_called() + coordinator.async_refresh.assert_not_called() async def test_set_temperature_changes_setpoint(mock_thermostat_device) -> None: """Test that setting temperature actually changes the device setpoint.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) assert climate_entity.target_temperature == 22.0 - async def mock_refresh_device(device_id): + async def mock_refresh(): mock_thermostat_device.setpoint = 25.0 - coordinator.async_refresh_device.side_effect = mock_refresh_device + coordinator.async_refresh.side_effect = mock_refresh await climate_entity.async_set_temperature(temperature=25.0) @@ -272,16 +241,14 @@ async def mock_refresh_device(device_id): climate_entity.device_id, 25.0 ) - coordinator.async_refresh_device.assert_called_once_with(climate_entity.device_id) + coordinator.async_refresh.assert_called_once() assert climate_entity.target_temperature == 25.0 async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) -> None: """Test that temperature doesn't change when API call fails.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( "API Error" ) @@ -290,22 +257,21 @@ async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) initial_temp = climate_entity.target_temperature assert initial_temp == 22.0 - await climate_entity.async_set_temperature(temperature=25.0) + with pytest.raises(HomeAssistantError, match="Error setting temperature"): + await climate_entity.async_set_temperature(temperature=25.0) coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 25.0 ) - coordinator.async_refresh_device.assert_not_called() + coordinator.async_refresh.assert_not_called() assert climate_entity.target_temperature == initial_temp async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: """Test HVAC mode setting to heat.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_set_hvac_mode(HVACMode.HEAT) @@ -316,9 +282,7 @@ async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: """Test HVAC mode setting to off.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_set_hvac_mode(HVACMode.OFF) @@ -329,9 +293,7 @@ async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: """Test HVAC mode setting to auto.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) await climate_entity.async_set_hvac_mode(HVACMode.AUTO) @@ -342,24 +304,22 @@ async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: async def test_set_hvac_mode_unsupported(mock_thermostat_device) -> None: """Test setting unsupported HVAC mode.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - await climate_entity.async_set_hvac_mode(HVACMode.FAN_ONLY) + with pytest.raises(KeyError): + await climate_entity.async_set_hvac_mode(HVACMode.FAN_ONLY) coordinator.client.set_thermostat_mode.assert_not_called() async def test_set_hvac_mode_error(mock_thermostat_device) -> None: """Test HVAC mode setting with API error.""" - coordinator = create_coordinator( - {mock_thermostat_device.device_id: mock_thermostat_device} - ) + coordinator = create_device_coordinator(mock_thermostat_device) coordinator.client.set_thermostat_mode.side_effect = RuntimeError("API Error") climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - await climate_entity.async_set_hvac_mode(HVACMode.HEAT) + with pytest.raises(HomeAssistantError, match="Error setting HVAC mode"): + await climate_entity.async_set_hvac_mode(HVACMode.HEAT) coordinator.client.set_thermostat_mode.assert_called_once_with( climate_entity.device_id, ThermostatMode.COMFORT @@ -372,9 +332,6 @@ async def test_async_setup_entry_with_thermostat_devices( """Test setup entry with thermostat devices.""" async_add_entities = MagicMock(spec=AddEntitiesCallback) - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - thermostat_device = MagicMock(spec=ThermostatDevice) thermostat_device.device_id = "thermostat_1" thermostat_device.device_name = "Test Thermostat 1" @@ -389,11 +346,13 @@ async def test_async_setup_entry_with_thermostat_devices( thermostat_device.room_name = "Bedroom" thermostat_device.available_thermostat_modes = ["Program", "Eco", "Comfort", "Off"] - coordinator.data = {"thermostat_1": thermostat_device} + device_coordinator = create_device_coordinator(thermostat_device) + device_coordinators = {"thermostat_1": device_coordinator} entry = MagicMock(spec=ConfigEntry) entry.runtime_data = WattsVisionRuntimeData( - coordinator=coordinator, + hub_coordinator=MagicMock(), + device_coordinators=device_coordinators, auth=MagicMock(), client=MagicMock(), ) @@ -412,13 +371,10 @@ async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> Non """Test setup entry with empty coordinator data.""" async_add_entities = MagicMock(spec=AddEntitiesCallback) - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - coordinator.data = {} - entry = MagicMock(spec=ConfigEntry) entry.runtime_data = WattsVisionRuntimeData( - coordinator=coordinator, + hub_coordinator=MagicMock(), + device_coordinators={}, auth=MagicMock(), client=MagicMock(), ) @@ -435,9 +391,6 @@ async def test_async_setup_entry_multiple_thermostat_devices( """Test setup entry with multiple thermostat devices.""" async_add_entities = MagicMock(spec=AddEntitiesCallback) - coordinator = MagicMock(spec=WattsVisionCoordinator) - coordinator.last_update_success = True - thermostat1 = MagicMock(spec=ThermostatDevice) thermostat1.device_id = "thermostat_1" thermostat1.device_name = "Thermostat 1" @@ -446,11 +399,15 @@ async def test_async_setup_entry_multiple_thermostat_devices( thermostat2.device_id = "thermostat_2" thermostat2.device_name = "Thermostat 2" - coordinator.data = {"thermostat_1": thermostat1, "thermostat_2": thermostat2} + device_coordinators = { + "thermostat_1": create_device_coordinator(thermostat1), + "thermostat_2": create_device_coordinator(thermostat2), + } entry = MagicMock(spec=ConfigEntry) entry.runtime_data = WattsVisionRuntimeData( - coordinator=coordinator, + hub_coordinator=MagicMock(), + device_coordinators=device_coordinators, auth=MagicMock(), client=MagicMock(), ) @@ -463,3 +420,25 @@ async def test_async_setup_entry_multiple_thermostat_devices( assert len(entities) == 2 assert all(isinstance(entity, WattsVisionClimate) for entity in entities) assert args[1]["update_before_add"] is True + + +async def test_async_setup_entry_device_coordinator_no_data( + mock_hass, mock_config_entry +) -> None: + """Test setup entry when device coordinator has no data.""" + async_add_entities = MagicMock(spec=AddEntitiesCallback) + + device_coordinator = create_device_coordinator(None) + device_coordinators = {"thermostat_1": device_coordinator} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = WattsVisionRuntimeData( + hub_coordinator=MagicMock(), + device_coordinators=device_coordinators, + auth=MagicMock(), + client=MagicMock(), + ) + + await async_setup_entry(mock_hass, entry, async_add_entities) + + async_add_entities.assert_called_once_with([], update_before_add=True) diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 47cb87b18e4ac3..40defcc11242ea 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -58,7 +58,7 @@ async def test_full_flow( return_value="user123", ), patch( - "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh", + "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", return_value=AsyncMock(), ), ): @@ -299,7 +299,7 @@ async def test_unique_config_entry_full_flow( return_value="user123", ), patch( - "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh", + "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", return_value=AsyncMock(), ), ): diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py index 7f36f28536b03e..6e6a0180025927 100644 --- a/tests/components/watts/test_coordinator.py +++ b/tests/components/watts/test_coordinator.py @@ -6,7 +6,10 @@ from visionpluspython.client import WattsVisionClient from visionpluspython.models import Device -from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.components.watts.coordinator import ( + WattsVisionDeviceCoordinator, + WattsVisionHubCoordinator, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -47,111 +50,156 @@ def mock_device(): @pytest.fixture -def coordinator(mock_hass, mock_client, mock_config_entry): - """Create a WattsVisionCoordinator instance.""" - return WattsVisionCoordinator(mock_hass, mock_client, mock_config_entry) +def hub_coordinator(mock_hass, mock_client, mock_config_entry): + """Create a WattsVisionHubCoordinator instance.""" + return WattsVisionHubCoordinator(mock_hass, mock_client, mock_config_entry) -async def test_coordinator_initialization(coordinator, mock_hass, mock_client) -> None: - """Test coordinator initialization.""" - assert coordinator.hass == mock_hass - assert coordinator.client == mock_client - assert coordinator.name == DOMAIN - assert coordinator.update_interval.total_seconds() == UPDATE_INTERVAL - assert coordinator._is_initialized is False - assert coordinator._devices == {} - - -async def test_async_config_entry_first_refresh_success( - coordinator, mock_client, mock_device -) -> None: - """Test successful initial device discovery.""" - mock_client.discover_devices.return_value = [mock_device] - - await coordinator.async_config_entry_first_refresh() - - mock_client.discover_devices.assert_called_once() - assert coordinator._is_initialized is True - assert coordinator._devices == {mock_device.device_id: mock_device} - - -async def test_async_config_entry_first_refresh_failure( - coordinator, mock_client -) -> None: - """Test failed initial device discovery.""" - mock_client.discover_devices.side_effect = ConnectionError("API error") - - with pytest.raises(UpdateFailed): - await coordinator.async_config_entry_first_refresh() - - assert coordinator._is_initialized is False - assert coordinator._devices == {} - - -async def test_async_refresh_device_success( - coordinator, mock_client, mock_device -) -> None: - """Test refreshing a specific device successfully.""" - coordinator._devices = {mock_device.device_id: mock_device} - coordinator._is_initialized = True - mock_client.get_device.return_value = mock_device - - await coordinator.async_refresh_device(mock_device.device_id) - - mock_client.get_device.assert_called_once_with(mock_device.device_id, refresh=True) - assert coordinator._devices[mock_device.device_id] == mock_device - - -async def test_async_refresh_device_failure( - coordinator, mock_client, mock_device -) -> None: - """Test refreshing a specific device when API call fails.""" - coordinator._devices = {mock_device.device_id: mock_device} - coordinator._is_initialized = True - mock_client.get_device.side_effect = ConnectionError("Refresh error") - - await coordinator.async_refresh_device(mock_device.device_id) - - mock_client.get_device.assert_called_once_with(mock_device.device_id, refresh=True) - assert coordinator._devices[mock_device.device_id] == mock_device # Unchanged - - -async def test_async_update_data_not_initialized( - coordinator, mock_client, mock_device -) -> None: - """Test _async_update_data when coordinator is not initialized.""" - mock_client.discover_devices.return_value = [mock_device] - - result = await coordinator._async_update_data() - - mock_client.discover_devices.assert_called_once() - assert coordinator._is_initialized is True - assert result == {mock_device.device_id: mock_device} - - -async def test_async_update_data_no_devices(coordinator, mock_client) -> None: - """Test _async_update_data when no devices are known.""" - coordinator._is_initialized = True - coordinator._devices = {} - - result = await coordinator._async_update_data() - - mock_client.get_devices_report.assert_not_called() - assert result == {} - +@pytest.fixture +def device_coordinator(mock_hass, mock_client, mock_config_entry): + """Create a WattsVisionDeviceCoordinator instance.""" + return WattsVisionDeviceCoordinator( + mock_hass, mock_client, mock_config_entry, "device_123" + ) + + +class TestWattsVisionHubCoordinator: + """Test the hub coordinator.""" + + async def test_hub_coordinator_initialization( + self, hub_coordinator, mock_hass, mock_client + ) -> None: + """Test hub coordinator initialization.""" + assert hub_coordinator.hass == mock_hass + assert hub_coordinator.client == mock_client + assert hub_coordinator.name == DOMAIN + assert hub_coordinator.update_interval.total_seconds() == UPDATE_INTERVAL + assert hub_coordinator._is_initialized is False + assert hub_coordinator._devices == {} + + async def test_async_config_entry_first_refresh_success( + self, hub_coordinator, mock_client, mock_device + ) -> None: + """Test successful initial device discovery.""" + mock_client.discover_devices.return_value = [mock_device] + + await hub_coordinator.async_config_entry_first_refresh() + + mock_client.discover_devices.assert_called_once() + assert hub_coordinator._is_initialized is True + assert hub_coordinator._devices == {mock_device.device_id: mock_device} + + async def test_async_config_entry_first_refresh_failure( + self, hub_coordinator, mock_client + ) -> None: + """Test failed initial device discovery.""" + mock_client.discover_devices.side_effect = ConnectionError("API error") + + with pytest.raises(UpdateFailed): + await hub_coordinator.async_config_entry_first_refresh() + + assert hub_coordinator._is_initialized is False + assert hub_coordinator._devices == {} + + async def test_async_update_data_not_initialized( + self, hub_coordinator, mock_client, mock_device + ) -> None: + """Test _async_update_data when coordinator is not initialized.""" + mock_client.discover_devices.return_value = [mock_device] + + result = await hub_coordinator._async_update_data() + + mock_client.discover_devices.assert_called_once() + assert hub_coordinator._is_initialized is True + assert result == {mock_device.device_id: mock_device} + + async def test_async_update_data_no_devices( + self, hub_coordinator, mock_client + ) -> None: + """Test _async_update_data when no devices are known.""" + hub_coordinator._is_initialized = True + hub_coordinator._devices = {} + + result = await hub_coordinator._async_update_data() + + mock_client.get_devices_report.assert_not_called() + assert result == {} + + async def test_async_update_data_success( + self, hub_coordinator, mock_client, mock_device + ) -> None: + """Test successful device report update.""" + hub_coordinator._is_initialized = True + hub_coordinator._devices = {mock_device.device_id: mock_device} + updated_device = MagicMock(spec=Device) + updated_device.device_id = mock_device.device_id + mock_client.get_devices_report.return_value = { + mock_device.device_id: updated_device + } + + result = await hub_coordinator._async_update_data() + + mock_client.get_devices_report.assert_called_once_with([mock_device.device_id]) + assert hub_coordinator._devices[mock_device.device_id] == updated_device + assert result == {mock_device.device_id: updated_device} + + async def test_device_ids_property(self, hub_coordinator, mock_device) -> None: + """Test device_ids property.""" + hub_coordinator._devices = {mock_device.device_id: mock_device} + + assert hub_coordinator.device_ids == [mock_device.device_id] + + +class TestWattsVisionDeviceCoordinator: + """Test the device coordinator.""" + + async def test_device_coordinator_initialization( + self, device_coordinator, mock_hass, mock_client + ) -> None: + """Test device coordinator initialization.""" + assert device_coordinator.hass == mock_hass + assert device_coordinator.client == mock_client + assert device_coordinator.name == f"{DOMAIN}_device_123" + assert device_coordinator.update_interval is None # Manual refresh only + assert device_coordinator.device_id == "device_123" + + async def test_async_set_updated_data( + self, device_coordinator, mock_device + ) -> None: + """Test setting initial data from hub coordinator.""" + device_coordinator.async_set_updated_data(mock_device) + + assert device_coordinator.data == mock_device + + async def test_async_update_data_success( + self, device_coordinator, mock_client, mock_device + ) -> None: + """Test successful device refresh.""" + mock_client.get_device.return_value = mock_device -async def test_async_update_data_success(coordinator, mock_client, mock_device) -> None: - """Test successful device report update.""" - coordinator._is_initialized = True - coordinator._devices = {mock_device.device_id: mock_device} - updated_device = MagicMock(spec=Device) - updated_device.device_id = mock_device.device_id - mock_client.get_devices_report.return_value = { - mock_device.device_id: updated_device - } + result = await device_coordinator._async_update_data() + + mock_client.get_device.assert_called_once_with("device_123", refresh=True) + assert result == mock_device - result = await coordinator._async_update_data() + async def test_async_update_data_device_not_found( + self, device_coordinator, mock_client + ) -> None: + """Test device refresh when device is not found.""" + mock_client.get_device.return_value = None + + result = await device_coordinator._async_update_data() + + mock_client.get_device.assert_called_once_with("device_123", refresh=True) + assert result is None - mock_client.get_devices_report.assert_called_once_with([mock_device.device_id]) - assert coordinator._devices[mock_device.device_id] == updated_device - assert result == {mock_device.device_id: updated_device} + async def test_async_update_data_failure( + self, device_coordinator, mock_client + ) -> None: + """Test device refresh when API call fails.""" + mock_client.get_device.side_effect = ConnectionError("Refresh error") + + with pytest.raises(UpdateFailed): + await device_coordinator._async_update_data() + + mock_client.get_device.assert_called_once_with("device_123", refresh=True) diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index 37f7a7556acd6f..4873d35938480b 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -1,13 +1,17 @@ """Test the Watts Vision integration initialization.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError, ClientResponseError from visionpluspython import WattsVisionClient from visionpluspython.auth import WattsVisionAuth +from visionpluspython.models import Device from homeassistant.components.watts import WattsVisionRuntimeData, async_unload_entry -from homeassistant.components.watts.coordinator import WattsVisionCoordinator +from homeassistant.components.watts.coordinator import ( + WattsVisionDeviceCoordinator, + WattsVisionHubCoordinator, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -37,6 +41,10 @@ async def test_setup_entry_success(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) + mock_device = MagicMock(spec=Device) + mock_device.device_id = "device_123" + mock_device.device_name = "Test Device" + with ( patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" @@ -44,11 +52,14 @@ async def test_setup_entry_success(hass: HomeAssistant) -> None: patch( "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" ) as mock_session, - patch( - "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh" - ) as mock_first_refresh, patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, + patch( + "homeassistant.components.watts.WattsVisionHubCoordinator" + ) as mock_hub_class, + patch( + "homeassistant.components.watts.WattsVisionDeviceCoordinator" + ) as mock_device_coordinator_class, ): mock_implementation = AsyncMock() mock_implementation.client_id = "test-client-id" @@ -70,14 +81,23 @@ async def test_setup_entry_success(hass: HomeAssistant) -> None: mock_client_instance = AsyncMock() mock_client_class.return_value = mock_client_instance - mock_first_refresh.return_value = None + # Set up hub coordinator mock + mock_hub = mock_hub_class.return_value + mock_hub.device_ids = ["device_123"] + mock_hub.data = {"device_123": mock_device} + mock_hub.async_config_entry_first_refresh = AsyncMock() + + # Set up device coordinator mock + mock_device_coord = mock_device_coordinator_class.return_value + mock_device_coord.async_set_updated_data = MagicMock() result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert result is True assert config_entry.state is ConfigEntryState.LOADED - mock_first_refresh.assert_called_once() + mock_hub.async_config_entry_first_refresh.assert_called_once() + mock_device_coord.async_set_updated_data.assert_called_once_with(mock_device) # Test unload unload_result = await hass.config_entries.async_unload(config_entry.entry_id) @@ -178,8 +198,8 @@ async def test_setup_entry_not_ready(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_entry_coordinator_update_failed(hass: HomeAssistant) -> None: - """Test setup when coordinator update fails.""" +async def test_setup_entry_hub_coordinator_update_failed(hass: HomeAssistant) -> None: + """Test setup when hub coordinator update fails.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ @@ -203,8 +223,8 @@ async def test_setup_entry_coordinator_update_failed(hass: HomeAssistant) -> Non patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, patch( - "homeassistant.components.watts.WattsVisionCoordinator.async_config_entry_first_refresh" - ) as mock_first_refresh, + "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh" + ) as mock_hub_first_refresh, ): mock_implementation = AsyncMock() mock_implementation.client_id = "test-client-id" @@ -225,7 +245,9 @@ async def test_setup_entry_coordinator_update_failed(hass: HomeAssistant) -> Non mock_client_instance = AsyncMock() mock_client_class.return_value = mock_client_instance - mock_first_refresh.side_effect = UpdateFailed("Coordinator update failed") + mock_hub_first_refresh.side_effect = UpdateFailed( + "Hub coordinator update failed" + ) result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -242,15 +264,17 @@ async def test_unload_entry_success(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) - # Mock the runtime data + # Mock the runtime data with new structure mock_client = AsyncMock(spec=WattsVisionClient) mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + mock_hub_coordinator = AsyncMock(spec=WattsVisionHubCoordinator) + mock_device_coordinator = AsyncMock(spec=WattsVisionDeviceCoordinator) config_entry.runtime_data = WattsVisionRuntimeData( client=mock_client, auth=mock_auth, - coordinator=mock_coordinator, + hub_coordinator=mock_hub_coordinator, + device_coordinators={"device_123": mock_device_coordinator}, ) with patch( @@ -273,12 +297,14 @@ async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: # Mock the runtime data mock_client = AsyncMock(spec=WattsVisionClient) mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_coordinator = AsyncMock(spec=WattsVisionCoordinator) + mock_hub_coordinator = AsyncMock(spec=WattsVisionHubCoordinator) + mock_device_coordinator = AsyncMock(spec=WattsVisionDeviceCoordinator) config_entry.runtime_data = WattsVisionRuntimeData( client=mock_client, auth=mock_auth, - coordinator=mock_coordinator, + hub_coordinator=mock_hub_coordinator, + device_coordinators={"device_123": mock_device_coordinator}, ) with patch( From 7bb51866dad9107869791da5d59d1160670c09f4 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Tue, 14 Oct 2025 08:28:27 +0000 Subject: [PATCH 13/43] Add fast pooling after command --- homeassistant/components/watts/climate.py | 14 ++--- homeassistant/components/watts/const.py | 2 +- homeassistant/components/watts/coordinator.py | 21 ++++++- tests/components/watts/test_climate.py | 21 +++++-- tests/components/watts/test_coordinator.py | 55 ++++++++++++++++++- 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 2006de81ade58f..4982589b626205 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from typing import Any @@ -19,11 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WattsVisionConfigEntry -from .const import ( - HVAC_MODE_TO_THERMOSTAT, - THERMOSTAT_MODE_TO_HVAC, - UPDATE_DELAY_AFTER_COMMAND, -) +from .const import HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC from .coordinator import WattsVisionDeviceCoordinator from .entity import WattsVisionEntity @@ -109,7 +104,8 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self.device_id, ) - await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) + self.coordinator.trigger_fast_polling() + await self.coordinator.async_refresh() except RuntimeError as err: @@ -130,10 +126,12 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: mode.name, self.device_id, ) + + self.coordinator.trigger_fast_polling() + except (ValueError, RuntimeError) as err: raise HomeAssistantError( f"Error setting HVAC mode for {self.device_id}: {err}" ) from err - await asyncio.sleep(UPDATE_DELAY_AFTER_COMMAND) await self.coordinator.async_refresh() diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index f96a5b6a917cf6..60cd0d6ca01481 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -16,7 +16,7 @@ ] UPDATE_INTERVAL = 30 -UPDATE_DELAY_AFTER_COMMAND = 7 +FAST_POLLING_INTERVAL = 5 # Mapping from Watts Vision + modes to Home Assistant HVAC modes diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c99819f54421a7..2385dabd6e2537 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from visionpluspython.client import WattsVisionClient @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN, FAST_POLLING_INTERVAL, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -102,9 +102,18 @@ def __init__( ) self.client = client self.device_id = device_id + self._fast_polling_until: datetime | None = None async def _async_update_data(self) -> Device | None: """Refresh specific 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 (ConnectionError, TimeoutError, ValueError) as err: @@ -118,3 +127,11 @@ async def _async_update_data(self) -> Device | None: return device _LOGGER.warning("Device %s not found during refresh", self.device_id) return None + + 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) + _LOGGER.debug( + "Device %s: Activated fast polling for %d seconds", self.device_id, duration + ) diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 506e43502d1321..de561fed3f283b 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -26,6 +26,7 @@ def create_device_coordinator(device=None): coordinator.client.set_thermostat_temperature = AsyncMock() coordinator.client.set_thermostat_mode = AsyncMock() coordinator.async_refresh = AsyncMock() + coordinator.trigger_fast_polling = MagicMock() coordinator.last_update_success = True coordinator.available = device is not None return coordinator @@ -179,7 +180,7 @@ async def test_set_temperature_success(mock_thermostat_device) -> None: coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 23.5 ) - + coordinator.trigger_fast_polling.assert_called_once() coordinator.async_refresh.assert_called_once() @@ -192,7 +193,7 @@ async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 24.0 ) - + coordinator.trigger_fast_polling.assert_called_once() coordinator.async_refresh.assert_called_once() @@ -203,6 +204,7 @@ async def test_set_temperature_no_temperature(mock_thermostat_device) -> None: await climate_entity.async_set_temperature() coordinator.client.set_thermostat_temperature.assert_not_called() + coordinator.trigger_fast_polling.assert_not_called() coordinator.async_refresh.assert_not_called() @@ -220,6 +222,7 @@ async def test_set_temperature_error(mock_thermostat_device) -> None: coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 23.5 ) + coordinator.trigger_fast_polling.assert_not_called() coordinator.async_refresh.assert_not_called() @@ -240,7 +243,7 @@ async def mock_refresh(): coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 25.0 ) - + coordinator.trigger_fast_polling.assert_called_once() coordinator.async_refresh.assert_called_once() assert climate_entity.target_temperature == 25.0 @@ -263,7 +266,7 @@ async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) coordinator.client.set_thermostat_temperature.assert_called_once_with( climate_entity.device_id, 25.0 ) - + coordinator.trigger_fast_polling.assert_not_called() coordinator.async_refresh.assert_not_called() assert climate_entity.target_temperature == initial_temp @@ -278,6 +281,8 @@ async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: coordinator.client.set_thermostat_mode.assert_called_once_with( climate_entity.device_id, ThermostatMode.COMFORT ) + coordinator.trigger_fast_polling.assert_called_once() + coordinator.async_refresh.assert_called_once() async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: @@ -289,6 +294,8 @@ async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: coordinator.client.set_thermostat_mode.assert_called_once_with( climate_entity.device_id, ThermostatMode.OFF ) + coordinator.trigger_fast_polling.assert_called_once() + coordinator.async_refresh.assert_called_once() async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: @@ -300,6 +307,8 @@ async def test_set_hvac_mode_auto_success(mock_thermostat_device) -> None: coordinator.client.set_thermostat_mode.assert_called_once_with( climate_entity.device_id, ThermostatMode.PROGRAM ) + coordinator.trigger_fast_polling.assert_called_once() + coordinator.async_refresh.assert_called_once() async def test_set_hvac_mode_unsupported(mock_thermostat_device) -> None: @@ -310,6 +319,8 @@ async def test_set_hvac_mode_unsupported(mock_thermostat_device) -> None: with pytest.raises(KeyError): await climate_entity.async_set_hvac_mode(HVACMode.FAN_ONLY) coordinator.client.set_thermostat_mode.assert_not_called() + coordinator.trigger_fast_polling.assert_not_called() + coordinator.async_refresh.assert_not_called() async def test_set_hvac_mode_error(mock_thermostat_device) -> None: @@ -324,6 +335,8 @@ async def test_set_hvac_mode_error(mock_thermostat_device) -> None: coordinator.client.set_thermostat_mode.assert_called_once_with( climate_entity.device_id, ThermostatMode.COMFORT ) + coordinator.trigger_fast_polling.assert_not_called() + coordinator.async_refresh.assert_not_called() async def test_async_setup_entry_with_thermostat_devices( diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py index 6e6a0180025927..af430599aa521f 100644 --- a/tests/components/watts/test_coordinator.py +++ b/tests/components/watts/test_coordinator.py @@ -1,6 +1,7 @@ """Tests for the Watts Vision coordinator.""" -from unittest.mock import AsyncMock, MagicMock +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch import pytest from visionpluspython.client import WattsVisionClient @@ -162,6 +163,7 @@ async def test_device_coordinator_initialization( assert device_coordinator.name == f"{DOMAIN}_device_123" assert device_coordinator.update_interval is None # Manual refresh only assert device_coordinator.device_id == "device_123" + assert device_coordinator._fast_polling_until is None async def test_async_set_updated_data( self, device_coordinator, mock_device @@ -203,3 +205,54 @@ async def test_async_update_data_failure( await device_coordinator._async_update_data() mock_client.get_device.assert_called_once_with("device_123", refresh=True) + + async def test_trigger_fast_polling(self, device_coordinator) -> None: + """Test triggering fast polling on device coordinator.""" + with patch( + "homeassistant.components.watts.coordinator.datetime" + ) as mock_datetime: + mock_now = datetime(2023, 1, 1, 12, 0, 0) + mock_datetime.now.return_value = mock_now + + device_coordinator.trigger_fast_polling(60) + + assert device_coordinator._fast_polling_until == mock_now + timedelta( + seconds=60 + ) + assert device_coordinator.update_interval == timedelta(seconds=5) + + async def test_fast_polling_expires( + self, device_coordinator, mock_client, mock_device + ) -> None: + """Test that fast polling expires and returns to manual refresh.""" + mock_client.get_device.return_value = mock_device + + # Set up fast polling that has expired + past_time = datetime.now() - timedelta(seconds=1) + device_coordinator._fast_polling_until = past_time + device_coordinator.update_interval = timedelta(seconds=5) + + result = await device_coordinator._async_update_data() + + # Should have reset fast polling + assert device_coordinator._fast_polling_until is None + assert device_coordinator.update_interval is None + assert result == mock_device + + async def test_fast_polling_active( + self, device_coordinator, mock_client, mock_device + ) -> None: + """Test that fast polling remains active when not expired.""" + mock_client.get_device.return_value = mock_device + + # Set up fast polling that hasn't expired + future_time = datetime.now() + timedelta(seconds=30) + device_coordinator._fast_polling_until = future_time + device_coordinator.update_interval = timedelta(seconds=5) + + result = await device_coordinator._async_update_data() + + # Should keep fast polling active + assert device_coordinator._fast_polling_until == future_time + assert device_coordinator.update_interval == timedelta(seconds=5) + assert result == mock_device From d63ebdba403847e92cf09c7fe8985296fa69d0c5 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:45:09 +0200 Subject: [PATCH 14/43] Update homeassistant/components/watts/climate.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/climate.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 4982589b626205..619330ad23848e 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -35,12 +35,9 @@ async def async_setup_entry( device_coordinators = entry.runtime_data.device_coordinators async_add_entities( - [ - WattsVisionClimate(device_coordinator, device_coordinator.data) - for device_coordinator in device_coordinators.values() - if device_coordinator.data - ], - update_before_add=True, + WattsVisionClimate(device_coordinator, device_coordinator.data) + for device_coordinator in device_coordinators.values() + if device_coordinator.data ) From c4b4363a20e71d8f108ab94c8e2eeddcf217865d Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:45:23 +0200 Subject: [PATCH 15/43] Update homeassistant/components/watts/climate.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/climate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 619330ad23848e..bd999376500ee8 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -81,9 +81,6 @@ def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode) - async def async_request_refresh(self) -> None: - """Request refresh for this specific entity only.""" - await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" From 933120d2ce2f037eebe18bc9ca78f05ccb09d275 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:45:47 +0200 Subject: [PATCH 16/43] Update homeassistant/components/watts/coordinator.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 2385dabd6e2537..5d900417747c3e 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -89,7 +89,7 @@ def __init__( self, hass: HomeAssistant, client: WattsVisionClient, - config_entry: ConfigEntry, + config_entry: WattsVisionConfigEntry, device_id: str, ) -> None: """Initialize the device coordinator.""" From cb80a5092438094bdd9f0ac927514b9214a291b6 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 16 Oct 2025 12:28:22 +0000 Subject: [PATCH 17/43] Imrpove new coordinator functions --- homeassistant/components/watts/__init__.py | 2 +- homeassistant/components/watts/climate.py | 41 +++++------ homeassistant/components/watts/coordinator.py | 71 +++++++++---------- homeassistant/components/watts/entity.py | 15 +--- 4 files changed, 56 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index b290ceb8cd5827..1fe5cbcbc0d098 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) device_coordinators = {} for device_id in hub_coordinator.device_ids: device_coordinator = WattsVisionDeviceCoordinator( - hass, client, entry, device_id + hass, client, entry, hub_coordinator, device_id ) device_coordinator.async_set_updated_data(hub_coordinator.data[device_id]) device_coordinators[device_id] = device_coordinator diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index bd999376500ee8..14e09bfe06b737 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -37,7 +37,6 @@ async def async_setup_entry( async_add_entities( WattsVisionClimate(device_coordinator, device_coordinator.data) for device_coordinator in device_coordinators.values() - if device_coordinator.data ) @@ -81,7 +80,6 @@ def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode) - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) @@ -92,40 +90,39 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self.coordinator.client.set_thermostat_temperature( self.device_id, temperature ) - _LOGGER.debug( - "Successfully set temperature to %s for %s", - temperature, - self.device_id, - ) - - self.coordinator.trigger_fast_polling() - - await self.coordinator.async_refresh() - except RuntimeError as err: raise HomeAssistantError( f"Error setting temperature for {self.device_id}: {err}" ) 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) - _LOGGER.debug( - "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", - hvac_mode, - mode.name, - self.device_id, - ) - - self.coordinator.trigger_fast_polling() - except (ValueError, RuntimeError) as err: raise HomeAssistantError( f"Error setting HVAC mode for {self.device_id}: {err}" ) 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/coordinator.py b/homeassistant/components/watts/coordinator.py index 5d900417747c3e..f0ab0076482761 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -32,64 +32,48 @@ def __init__( config_entry=config_entry, ) self.client = client - self._devices: dict[str, Device] = {} - self._is_initialized = False - - async def async_config_entry_first_refresh(self) -> None: - """Perform initial discovery of devices.""" - try: - await self._discover_devices() - self.async_set_updated_data(self._devices) - except (ConnectionError, TimeoutError, ValueError) as err: - _LOGGER.error("Initial device discovery failed: %s", err) - raise UpdateFailed(f"Initial discovery failed: {err}") from err - - async def _discover_devices(self) -> None: - """Discover devices from API.""" - devices_list = await self.client.discover_devices() - self._devices = {device.device_id: device for device in devices_list} - self._is_initialized = True - _LOGGER.info("Initial discovery completed with %d devices", len(self._devices)) async def _async_update_data(self) -> dict[str, Device]: """Fetch data from Watts Vision API for all devices.""" try: - if not self._is_initialized: + if not self.data: # First loading, discover devices - await self._discover_devices() + devices_list = await self.client.discover_devices() + devices = {device.device_id: device for device in devices_list} + _LOGGER.info( + "Initial discovery completed with %d devices", len(devices) + ) else: - device_ids = list(self._devices.keys()) + device_ids = list(self.data.keys()) if not device_ids: _LOGGER.warning("No devices to update") + devices = self.data else: - updated_devices = await self.client.get_devices_report(device_ids) - - for device_id, device in updated_devices.items(): - self._devices[device_id] = device - - _LOGGER.debug("Updated %d devices", len(updated_devices)) + devices = await self.client.get_devices_report(device_ids) + _LOGGER.debug("Updated %d devices", len(devices)) except (ConnectionError, TimeoutError, ValueError) as err: _LOGGER.error("API error during devices update: %s", err) raise UpdateFailed(f"API error during devices update: {err}") from err else: - return self._devices + return devices @property def device_ids(self) -> list[str]: """Get list of all device IDs.""" - return list(self._devices.keys()) + return list((self.data or {}).keys()) -class WattsVisionDeviceCoordinator(DataUpdateCoordinator[Device | None]): +class WattsVisionDeviceCoordinator(DataUpdateCoordinator[Device]): """Device coordinator for individual updates.""" def __init__( self, hass: HomeAssistant, client: WattsVisionClient, - config_entry: WattsVisionConfigEntry, + config_entry: ConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, device_id: str, ) -> None: """Initialize the device coordinator.""" @@ -102,9 +86,19 @@ def __init__( ) self.client = client self.device_id = device_id + self.hub_coordinator = hub_coordinator self._fast_polling_until: datetime | None = None - async def _async_update_data(self) -> Device | None: + # Listen to hub coordinator updates + 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] + self.async_set_updated_data(device) + + async def _async_update_data(self) -> Device: """Refresh specific device.""" if self._fast_polling_until and datetime.now() > self._fast_polling_until: self._fast_polling_until = None @@ -121,12 +115,13 @@ async def _async_update_data(self) -> Device | None: raise UpdateFailed( f"Failed to refresh device {self.device_id}: {err}" ) from err - else: - if device: - _LOGGER.debug("Refreshed device %s", self.device_id) - return device - _LOGGER.warning("Device %s not found during refresh", self.device_id) - return None + + if not device: + _LOGGER.error("Device %s not found during refresh", self.device_id) + raise UpdateFailed(f"Device {self.device_id} not found") + + _LOGGER.debug("Refreshed device %s", self.device_id) + return device def trigger_fast_polling(self, duration: int = 60) -> None: """Activate fast polling for a specified duration after a command.""" diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index 3b746dc7b735f5..7e7fa828cc0f39 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -26,29 +26,20 @@ def __init__( self._attr_unique_id = device_id if self.device: - device_name = self.device.device_name - if hasattr(self.device, "room_name") and self.device.room_name: - device_name = f"{self.device.room_name} {device_name}" - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device_id)}, - name=device_name, + name=self.device.device_name, manufacturer="Watts", model=f"Vision+ {self.device.device_type}", + suggested_area=self.device.room_name, ) @property def device(self) -> Device: """Return the device object from the coordinator data.""" - if self.coordinator.data is None: - raise RuntimeError("Empty device coordinator data") return self.coordinator.data @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self.coordinator.data is not None - and self.coordinator.data.is_online - ) + return super().available and self.coordinator.data.is_online From 89bc5340db854cf29cedd6bd42f685655ab7489b Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 16 Oct 2025 13:16:16 +0000 Subject: [PATCH 18/43] Create snapshot tests and remove old coordinators tests --- tests/components/watts/__init__.py | 51 +- tests/components/watts/conftest.py | 73 ++- .../watts/fixtures/device_detail.json | 22 + .../watts/fixtures/device_report.json | 39 ++ .../watts/fixtures/discover_devices.json | 39 ++ .../watts/snapshots/test_climate.ambr | 133 ++++ tests/components/watts/test_climate.py | 589 ++++++------------ tests/components/watts/test_coordinator.py | 258 -------- tests/components/watts/test_init.py | 271 ++------ 9 files changed, 591 insertions(+), 884 deletions(-) create mode 100644 tests/components/watts/fixtures/device_detail.json create mode 100644 tests/components/watts/fixtures/device_report.json create mode 100644 tests/components/watts/fixtures/discover_devices.json create mode 100644 tests/components/watts/snapshots/test_climate.ambr delete mode 100644 tests/components/watts/test_coordinator.py diff --git a/tests/components/watts/__init__.py b/tests/components/watts/__init__.py index f1ea49557b5100..fad150d642b6c2 100644 --- a/tests/components/watts/__init__.py +++ b/tests/components/watts/__init__.py @@ -1 +1,50 @@ -"""Tests for the Watts integration.""" +"""Tests for the Watts Vision integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Set up the Watts Vision integration for testing.""" + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + patch( + "homeassistant.components.watts.WattsVisionClient", + return_value=mock_client, + ), + patch( + "homeassistant.components.watts.WattsVisionAuth", + ) as mock_auth_class, + ): + # Mock OAuth2 implementation + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + # Mock OAuth2 session + mock_session_instance = AsyncMock() + mock_session_instance.token = config_entry.data["token"] + mock_session_instance.async_ensure_token_valid = AsyncMock() + mock_session.return_value = mock_session_instance + + # Mock auth + mock_auth_instance = AsyncMock() + mock_auth_class.return_value = mock_auth_instance + + 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 index 5bf39949b670cc..d5e4d73355e7b7 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -1,6 +1,11 @@ """Fixtures for the Watts integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, patch + import pytest +from visionpluspython.models import create_device_from_data from homeassistant.components.application_credentials import ( ClientCredential, @@ -10,14 +15,19 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry, load_fixture + CLIENT_ID = "test_client_id" CLIENT_SECRET = "test_client_secret" +TEST_DEVICE_ID = "test-device-id" +TEST_ACCESS_TOKEN = "test-access-token" +TEST_REFRESH_TOKEN = "test-refresh-token" +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( @@ -25,3 +35,64 @@ async def setup_credentials(hass: HomeAssistant) -> None: 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 = json.loads(load_fixture("discover_devices.json", DOMAIN)) + device_report_data = json.loads(load_fixture("device_report.json", DOMAIN)) + device_detail_data = json.loads(load_fixture("device_detail.json", DOMAIN)) + + discovered_devices = [ + create_device_from_data(device_data) for device_data in discover_data + ] + device_report = { + device_id: create_device_from_data(device_data) + for device_id, device_data in device_report_data.items() + } + device_detail = create_device_from_data(device_detail_data) + + client.discover_devices.return_value = discovered_devices + client.get_devices_report.return_value = device_report + client.get_device.return_value = device_detail + client.set_thermostat_temperature = AsyncMock() + client.set_thermostat_mode = AsyncMock() + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Watts Vision", + data={ + "device_id": TEST_DEVICE_ID, + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "expires_at": TEST_EXPIRES_AT, + }, + }, + entry_id="01J0BC4QM2YBRP6H5G933CETI8", + unique_id=TEST_DEVICE_ID, + ) diff --git a/tests/components/watts/fixtures/device_detail.json b/tests/components/watts/fixtures/device_detail.json new file mode 100644 index 00000000000000..dc9633d15c9cc8 --- /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 00000000000000..bf3467e769ee26 --- /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 00000000000000..0bb36039918b9b --- /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 00000000000000..88417d17cbbfbd --- /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_climate.py b/tests/components/watts/test_climate.py index de561fed3f283b..0d9e2557c0c900 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -1,457 +1,238 @@ """Tests for the Watts Vision climate platform.""" -from unittest.mock import AsyncMock, MagicMock +from datetime import timedelta +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest -from visionpluspython.models import ThermostatDevice, ThermostatMode - -from homeassistant.components.climate import HVACMode -from homeassistant.components.watts import WattsVisionRuntimeData -from homeassistant.components.watts.climate import WattsVisionClimate, async_setup_entry -from homeassistant.components.watts.coordinator import WattsVisionDeviceCoordinator -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +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.entity_platform import AddEntitiesCallback - -from tests.common import MockConfigEntry - - -def create_device_coordinator(device=None): - """Create a mock device coordinator.""" - coordinator = MagicMock(spec=WattsVisionDeviceCoordinator) - coordinator.data = device - coordinator.client = MagicMock() - coordinator.client.set_thermostat_temperature = AsyncMock() - coordinator.client.set_thermostat_mode = AsyncMock() - coordinator.async_refresh = AsyncMock() - coordinator.trigger_fast_polling = MagicMock() - coordinator.last_update_success = True - coordinator.available = device is not None - return coordinator - - -@pytest.fixture -def mock_hass(): - """Mock HomeAssistant instance.""" - return MagicMock(spec=HomeAssistant) - - -@pytest.fixture -def mock_config_entry(): - """Create a mock config entry.""" - return MockConfigEntry(domain="watts") - - -@pytest.fixture -def mock_thermostat_device(): - """Mock Watts Vision thermostat device.""" - device = MagicMock(spec=ThermostatDevice) - device.device_id = "thermostat_123" - device.device_name = "Test Thermostat" - device.current_temperature = 20.5 - device.setpoint = 22.0 - device.thermostat_mode = "Comfort" - device.min_allowed_temperature = 5.0 - device.max_allowed_temperature = 30.0 - device.temperature_unit = "C" - device.is_online = True - device.device_type = "thermostat" - device.room_name = "Living Room" - device.available_thermostat_modes = [ - "Program", - "Eco", - "Comfort", - "Off", - "Defrost", - "Timer", - ] - return device - - -async def test_climate_initialization(mock_thermostat_device) -> None: - """Test climate entity initialization.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate = WattsVisionClimate(coordinator, mock_thermostat_device) - - assert climate._device == mock_thermostat_device - assert climate._attr_unique_id == "thermostat_123" - - device_info = climate.device_info - assert device_info is not None - assert device_info["identifiers"] == {("watts", "thermostat_123")} - assert device_info["name"] == "Living Room Test Thermostat" - assert device_info["manufacturer"] == "Watts" - assert device_info["model"] == "Vision+ thermostat" - - assert climate._attr_min_temp == 5.0 - assert climate._attr_max_temp == 30.0 - assert climate._attr_temperature_unit == UnitOfTemperature.CELSIUS - - -def test_current_temperature(mock_thermostat_device) -> None: - """Test current temperature property.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.current_temperature == 20.5 - - -def test_target_temperature(mock_thermostat_device) -> None: - """Test target temperature property.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.target_temperature == 22.0 - - -def test_hvac_mode_comfort(mock_thermostat_device) -> None: - """Test HVAC mode property for Comfort mode.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode == HVACMode.HEAT - - -def test_hvac_mode_eco(mock_thermostat_device) -> None: - """Test HVAC mode mapping for Eco mode.""" - mock_thermostat_device.thermostat_mode = "Eco" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode == HVACMode.HEAT - - -def test_hvac_mode_program(mock_thermostat_device) -> None: - """Test HVAC mode mapping for Program mode.""" - mock_thermostat_device.thermostat_mode = "Program" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode == HVACMode.AUTO - - -def test_hvac_mode_off(mock_thermostat_device) -> None: - """Test HVAC mode mapping for Off mode.""" - mock_thermostat_device.thermostat_mode = "Off" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode == HVACMode.OFF - - -def test_hvac_mode_unknown(mock_thermostat_device) -> None: - """Test HVAC mode mapping for unknown mode.""" - mock_thermostat_device.thermostat_mode = "Unknown" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.hvac_mode is None - - -async def test_async_request_refresh(mock_thermostat_device) -> None: - """Test async_request_refresh method.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - await climate_entity.async_request_refresh() - - coordinator.async_refresh.assert_called_once() - +from homeassistant.helpers import entity_registry as er -def test_available_true(mock_thermostat_device) -> None: - """Test available property when device is online.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.available is True +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -def test_available_false_offline(mock_thermostat_device) -> None: - """Test available property when device is offline.""" - mock_thermostat_device.is_online = False - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - assert climate_entity.available is False +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, mock_watts_client) -async def test_set_temperature_success(mock_thermostat_device) -> None: - """Test temperature setting success.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - initial_temp = climate_entity.target_temperature - assert initial_temp == 22.0 - - await climate_entity.async_set_temperature(temperature=23.5) - coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity.device_id, 23.5 - ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() - - -async def test_set_temperature_with_attr_temperature(mock_thermostat_device) -> None: - """Test temperature setting using ATTR_TEMPERATURE.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - await climate_entity.async_set_temperature(**{ATTR_TEMPERATURE: 24.0}) - coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity.device_id, 24.0 - ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() - - -async def test_set_temperature_no_temperature(mock_thermostat_device) -> None: - """Test temperature setting without temperature parameter.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - await climate_entity.async_set_temperature() - coordinator.client.set_thermostat_temperature.assert_not_called() - coordinator.trigger_fast_polling.assert_not_called() - coordinator.async_refresh.assert_not_called() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_set_temperature_error(mock_thermostat_device) -> None: - """Test temperature setting with API error.""" - coordinator = create_device_coordinator(mock_thermostat_device) - coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( - "API Error" +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, mock_watts_client) + + 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, ) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - with pytest.raises(HomeAssistantError, match="Error setting temperature"): - await climate_entity.async_set_temperature(temperature=23.5) - coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity.device_id, 23.5 + mock_watts_client.set_thermostat_temperature.assert_called_once_with( + "thermostat_123", 23.5 ) - coordinator.trigger_fast_polling.assert_not_called() - coordinator.async_refresh.assert_not_called() - - -async def test_set_temperature_changes_setpoint(mock_thermostat_device) -> None: - """Test that setting temperature actually changes the device setpoint.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - assert climate_entity.target_temperature == 22.0 - async def mock_refresh(): - mock_thermostat_device.setpoint = 25.0 - coordinator.async_refresh.side_effect = mock_refresh - - await climate_entity.async_set_temperature(temperature=25.0) - - coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity.device_id, 25.0 +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, mock_watts_client) + + # 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, ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() - assert climate_entity.target_temperature == 25.0 + # 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() -async def test_set_temperature_no_change_on_api_failure(mock_thermostat_device) -> None: - """Test that temperature doesn't change when API call fails.""" - coordinator = create_device_coordinator(mock_thermostat_device) - coordinator.client.set_thermostat_temperature.side_effect = RuntimeError( - "API Error" - ) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - initial_temp = climate_entity.target_temperature - assert initial_temp == 22.0 + assert mock_watts_client.get_device.called + mock_watts_client.get_device.assert_called_with("thermostat_123", refresh=True) - with pytest.raises(HomeAssistantError, match="Error setting temperature"): - await climate_entity.async_set_temperature(temperature=25.0) - coordinator.client.set_thermostat_temperature.assert_called_once_with( - climate_entity.device_id, 25.0 +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, mock_watts_client) + + # 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, ) - coordinator.trigger_fast_polling.assert_not_called() - coordinator.async_refresh.assert_not_called() - - assert climate_entity.target_temperature == initial_temp + # Reset mock to count only fast polling calls + mock_watts_client.get_device.reset_mock() -async def test_set_hvac_mode_heat_success(mock_thermostat_device) -> None: - """Test HVAC mode setting to heat.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + # Should be in fast pooling 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() - await climate_entity.async_set_hvac_mode(HVACMode.HEAT) - coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity.device_id, ThermostatMode.COMFORT - ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() + 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() -async def test_set_hvac_mode_off_success(mock_thermostat_device) -> None: - """Test HVAC mode setting to off.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) + # Should be called one last time to check if duration expired, then stop - await climate_entity.async_set_hvac_mode(HVACMode.OFF) - coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity.device_id, ThermostatMode.OFF - ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() + # 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_auto_success(mock_thermostat_device) -> None: - """Test HVAC mode setting to auto.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - await climate_entity.async_set_hvac_mode(HVACMode.AUTO) - coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity.device_id, ThermostatMode.PROGRAM +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, mock_watts_client) + + 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, ) - coordinator.trigger_fast_polling.assert_called_once() - coordinator.async_refresh.assert_called_once() - - -async def test_set_hvac_mode_unsupported(mock_thermostat_device) -> None: - """Test setting unsupported HVAC mode.""" - coordinator = create_device_coordinator(mock_thermostat_device) - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - - with pytest.raises(KeyError): - await climate_entity.async_set_hvac_mode(HVACMode.FAN_ONLY) - coordinator.client.set_thermostat_mode.assert_not_called() - coordinator.trigger_fast_polling.assert_not_called() - coordinator.async_refresh.assert_not_called() - - -async def test_set_hvac_mode_error(mock_thermostat_device) -> None: - """Test HVAC mode setting with API error.""" - coordinator = create_device_coordinator(mock_thermostat_device) - coordinator.client.set_thermostat_mode.side_effect = RuntimeError("API Error") - climate_entity = WattsVisionClimate(coordinator, mock_thermostat_device) - with pytest.raises(HomeAssistantError, match="Error setting HVAC mode"): - await climate_entity.async_set_hvac_mode(HVACMode.HEAT) - - coordinator.client.set_thermostat_mode.assert_called_once_with( - climate_entity.device_id, ThermostatMode.COMFORT + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.COMFORT ) - coordinator.trigger_fast_polling.assert_not_called() - coordinator.async_refresh.assert_not_called() -async def test_async_setup_entry_with_thermostat_devices( - mock_hass, mock_config_entry +async def test_set_hvac_mode_auto( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test setup entry with thermostat devices.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - thermostat_device = MagicMock(spec=ThermostatDevice) - thermostat_device.device_id = "thermostat_1" - thermostat_device.device_name = "Test Thermostat 1" - thermostat_device.current_temperature = 21.0 - thermostat_device.setpoint = 23.0 - thermostat_device.thermostat_mode = "Program" - thermostat_device.min_allowed_temperature = 5.0 - thermostat_device.max_allowed_temperature = 30.0 - thermostat_device.temperature_unit = "C" - thermostat_device.is_online = True - thermostat_device.device_type = "thermostat" - thermostat_device.room_name = "Bedroom" - thermostat_device.available_thermostat_modes = ["Program", "Eco", "Comfort", "Off"] - - device_coordinator = create_device_coordinator(thermostat_device) - device_coordinators = {"thermostat_1": device_coordinator} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - hub_coordinator=MagicMock(), - device_coordinators=device_coordinators, - auth=MagicMock(), - client=MagicMock(), + """Test setting HVAC mode to auto.""" + await setup_integration(hass, mock_config_entry, mock_watts_client) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bedroom_thermostat", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, ) - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 1 - assert isinstance(entities[0], WattsVisionClimate) - assert args[1]["update_before_add"] is True - - -async def test_async_setup_entry_empty_data(mock_hass, mock_config_entry) -> None: - """Test setup entry with empty coordinator data.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - hub_coordinator=MagicMock(), - device_coordinators={}, - auth=MagicMock(), - client=MagicMock(), + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_456", ThermostatMode.PROGRAM ) - await async_setup_entry(mock_hass, entry, async_add_entities) - - # With empty data, async_add_entities is called with empty list - async_add_entities.assert_called_once_with([], update_before_add=True) - -async def test_async_setup_entry_multiple_thermostat_devices( - mock_hass, mock_config_entry +async def test_set_hvac_mode_off( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test setup entry with multiple thermostat devices.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - thermostat1 = MagicMock(spec=ThermostatDevice) - thermostat1.device_id = "thermostat_1" - thermostat1.device_name = "Thermostat 1" - - thermostat2 = MagicMock(spec=ThermostatDevice) - thermostat2.device_id = "thermostat_2" - thermostat2.device_name = "Thermostat 2" - - device_coordinators = { - "thermostat_1": create_device_coordinator(thermostat1), - "thermostat_2": create_device_coordinator(thermostat2), - } - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - hub_coordinator=MagicMock(), - device_coordinators=device_coordinators, - auth=MagicMock(), - client=MagicMock(), + """Test setting HVAC mode to off.""" + await setup_integration(hass, mock_config_entry, mock_watts_client) + + 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, ) - await async_setup_entry(mock_hass, entry, async_add_entities) - - async_add_entities.assert_called_once() - args = async_add_entities.call_args - entities = args[0][0] - assert len(entities) == 2 - assert all(isinstance(entity, WattsVisionClimate) for entity in entities) - assert args[1]["update_before_add"] is True + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.OFF + ) -async def test_async_setup_entry_device_coordinator_no_data( - mock_hass, mock_config_entry +async def test_set_temperature_api_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test setup entry when device coordinator has no data.""" - async_add_entities = MagicMock(spec=AddEntitiesCallback) - - device_coordinator = create_device_coordinator(None) - device_coordinators = {"thermostat_1": device_coordinator} - - entry = MagicMock(spec=ConfigEntry) - entry.runtime_data = WattsVisionRuntimeData( - hub_coordinator=MagicMock(), - device_coordinators=device_coordinators, - auth=MagicMock(), - client=MagicMock(), - ) + """Test error handling when setting temperature fails.""" + await setup_integration(hass, mock_config_entry, mock_watts_client) - await async_setup_entry(mock_hass, entry, async_add_entities) + # Make the API call fail + mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error") - async_add_entities.assert_called_once_with([], update_before_add=True) + with pytest.raises(HomeAssistantError, match="Error setting temperature"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) diff --git a/tests/components/watts/test_coordinator.py b/tests/components/watts/test_coordinator.py deleted file mode 100644 index af430599aa521f..00000000000000 --- a/tests/components/watts/test_coordinator.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for the Watts Vision coordinator.""" - -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from visionpluspython.client import WattsVisionClient -from visionpluspython.models import Device - -from homeassistant.components.watts.coordinator import ( - WattsVisionDeviceCoordinator, - WattsVisionHubCoordinator, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed - -DOMAIN = "watts" -UPDATE_INTERVAL = 30 - - -@pytest.fixture -def mock_hass(): - """Mock HomeAssistant instance.""" - return MagicMock(spec=HomeAssistant) - - -@pytest.fixture -def mock_config_entry(): - """Mock ConfigEntry instance.""" - return MagicMock(spec=ConfigEntry) - - -@pytest.fixture -def mock_client(): - """Mock WattsVisionClient instance.""" - client = MagicMock(spec=WattsVisionClient) - client.discover_devices = AsyncMock() - client.get_device = AsyncMock() - client.get_devices_report = AsyncMock() - return client - - -@pytest.fixture -def mock_device(): - """Mock Watts Vision device.""" - device = MagicMock(spec=Device) - device.device_id = "device_123" - device.device_name = "Test Device" - return device - - -@pytest.fixture -def hub_coordinator(mock_hass, mock_client, mock_config_entry): - """Create a WattsVisionHubCoordinator instance.""" - return WattsVisionHubCoordinator(mock_hass, mock_client, mock_config_entry) - - -@pytest.fixture -def device_coordinator(mock_hass, mock_client, mock_config_entry): - """Create a WattsVisionDeviceCoordinator instance.""" - return WattsVisionDeviceCoordinator( - mock_hass, mock_client, mock_config_entry, "device_123" - ) - - -class TestWattsVisionHubCoordinator: - """Test the hub coordinator.""" - - async def test_hub_coordinator_initialization( - self, hub_coordinator, mock_hass, mock_client - ) -> None: - """Test hub coordinator initialization.""" - assert hub_coordinator.hass == mock_hass - assert hub_coordinator.client == mock_client - assert hub_coordinator.name == DOMAIN - assert hub_coordinator.update_interval.total_seconds() == UPDATE_INTERVAL - assert hub_coordinator._is_initialized is False - assert hub_coordinator._devices == {} - - async def test_async_config_entry_first_refresh_success( - self, hub_coordinator, mock_client, mock_device - ) -> None: - """Test successful initial device discovery.""" - mock_client.discover_devices.return_value = [mock_device] - - await hub_coordinator.async_config_entry_first_refresh() - - mock_client.discover_devices.assert_called_once() - assert hub_coordinator._is_initialized is True - assert hub_coordinator._devices == {mock_device.device_id: mock_device} - - async def test_async_config_entry_first_refresh_failure( - self, hub_coordinator, mock_client - ) -> None: - """Test failed initial device discovery.""" - mock_client.discover_devices.side_effect = ConnectionError("API error") - - with pytest.raises(UpdateFailed): - await hub_coordinator.async_config_entry_first_refresh() - - assert hub_coordinator._is_initialized is False - assert hub_coordinator._devices == {} - - async def test_async_update_data_not_initialized( - self, hub_coordinator, mock_client, mock_device - ) -> None: - """Test _async_update_data when coordinator is not initialized.""" - mock_client.discover_devices.return_value = [mock_device] - - result = await hub_coordinator._async_update_data() - - mock_client.discover_devices.assert_called_once() - assert hub_coordinator._is_initialized is True - assert result == {mock_device.device_id: mock_device} - - async def test_async_update_data_no_devices( - self, hub_coordinator, mock_client - ) -> None: - """Test _async_update_data when no devices are known.""" - hub_coordinator._is_initialized = True - hub_coordinator._devices = {} - - result = await hub_coordinator._async_update_data() - - mock_client.get_devices_report.assert_not_called() - assert result == {} - - async def test_async_update_data_success( - self, hub_coordinator, mock_client, mock_device - ) -> None: - """Test successful device report update.""" - hub_coordinator._is_initialized = True - hub_coordinator._devices = {mock_device.device_id: mock_device} - updated_device = MagicMock(spec=Device) - updated_device.device_id = mock_device.device_id - mock_client.get_devices_report.return_value = { - mock_device.device_id: updated_device - } - - result = await hub_coordinator._async_update_data() - - mock_client.get_devices_report.assert_called_once_with([mock_device.device_id]) - assert hub_coordinator._devices[mock_device.device_id] == updated_device - assert result == {mock_device.device_id: updated_device} - - async def test_device_ids_property(self, hub_coordinator, mock_device) -> None: - """Test device_ids property.""" - hub_coordinator._devices = {mock_device.device_id: mock_device} - - assert hub_coordinator.device_ids == [mock_device.device_id] - - -class TestWattsVisionDeviceCoordinator: - """Test the device coordinator.""" - - async def test_device_coordinator_initialization( - self, device_coordinator, mock_hass, mock_client - ) -> None: - """Test device coordinator initialization.""" - assert device_coordinator.hass == mock_hass - assert device_coordinator.client == mock_client - assert device_coordinator.name == f"{DOMAIN}_device_123" - assert device_coordinator.update_interval is None # Manual refresh only - assert device_coordinator.device_id == "device_123" - assert device_coordinator._fast_polling_until is None - - async def test_async_set_updated_data( - self, device_coordinator, mock_device - ) -> None: - """Test setting initial data from hub coordinator.""" - device_coordinator.async_set_updated_data(mock_device) - - assert device_coordinator.data == mock_device - - async def test_async_update_data_success( - self, device_coordinator, mock_client, mock_device - ) -> None: - """Test successful device refresh.""" - mock_client.get_device.return_value = mock_device - - result = await device_coordinator._async_update_data() - - mock_client.get_device.assert_called_once_with("device_123", refresh=True) - assert result == mock_device - - async def test_async_update_data_device_not_found( - self, device_coordinator, mock_client - ) -> None: - """Test device refresh when device is not found.""" - mock_client.get_device.return_value = None - - result = await device_coordinator._async_update_data() - - mock_client.get_device.assert_called_once_with("device_123", refresh=True) - assert result is None - - async def test_async_update_data_failure( - self, device_coordinator, mock_client - ) -> None: - """Test device refresh when API call fails.""" - mock_client.get_device.side_effect = ConnectionError("Refresh error") - - with pytest.raises(UpdateFailed): - await device_coordinator._async_update_data() - - mock_client.get_device.assert_called_once_with("device_123", refresh=True) - - async def test_trigger_fast_polling(self, device_coordinator) -> None: - """Test triggering fast polling on device coordinator.""" - with patch( - "homeassistant.components.watts.coordinator.datetime" - ) as mock_datetime: - mock_now = datetime(2023, 1, 1, 12, 0, 0) - mock_datetime.now.return_value = mock_now - - device_coordinator.trigger_fast_polling(60) - - assert device_coordinator._fast_polling_until == mock_now + timedelta( - seconds=60 - ) - assert device_coordinator.update_interval == timedelta(seconds=5) - - async def test_fast_polling_expires( - self, device_coordinator, mock_client, mock_device - ) -> None: - """Test that fast polling expires and returns to manual refresh.""" - mock_client.get_device.return_value = mock_device - - # Set up fast polling that has expired - past_time = datetime.now() - timedelta(seconds=1) - device_coordinator._fast_polling_until = past_time - device_coordinator.update_interval = timedelta(seconds=5) - - result = await device_coordinator._async_update_data() - - # Should have reset fast polling - assert device_coordinator._fast_polling_until is None - assert device_coordinator.update_interval is None - assert result == mock_device - - async def test_fast_polling_active( - self, device_coordinator, mock_client, mock_device - ) -> None: - """Test that fast polling remains active when not expired.""" - mock_client.get_device.return_value = mock_device - - # Set up fast polling that hasn't expired - future_time = datetime.now() + timedelta(seconds=30) - device_coordinator._fast_polling_until = future_time - device_coordinator.update_interval = timedelta(seconds=5) - - result = await device_coordinator._async_update_data() - - # Should keep fast polling active - assert device_coordinator._fast_polling_until == future_time - assert device_coordinator.update_interval == timedelta(seconds=5) - assert result == mock_device diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index 4873d35938480b..20aed1608b7c6d 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -1,126 +1,43 @@ """Test the Watts Vision integration initialization.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from aiohttp import ClientError, ClientResponseError -from visionpluspython import WattsVisionClient -from visionpluspython.auth import WattsVisionAuth -from visionpluspython.models import Device - -from homeassistant.components.watts import WattsVisionRuntimeData, async_unload_entry -from homeassistant.components.watts.coordinator import ( - WattsVisionDeviceCoordinator, - WattsVisionHubCoordinator, -) + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed -from tests.common import MockConfigEntry +from . import setup_integration -DOMAIN = "watts" -TEST_DEVICE_ID = "test-device-id" -TEST_ACCESS_TOKEN = "test-access-token" -TEST_REFRESH_TOKEN = "test-refresh-token" -TEST_EXPIRES_AT = 9999999999 +from tests.common import MockConfigEntry -async def test_setup_entry_success(hass: HomeAssistant) -> None: +async def test_setup_entry_success( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test successful setup and unload of entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "device_id": TEST_DEVICE_ID, - "auth_implementation": DOMAIN, - "token": { - "access_token": TEST_ACCESS_TOKEN, - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - }, - }, - ) - config_entry.add_to_hass(hass) - - mock_device = MagicMock(spec=Device) - mock_device.device_id = "device_123" - mock_device.device_name = "Test Device" + await setup_integration(hass, mock_config_entry, mock_watts_client) - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, - patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, - patch( - "homeassistant.components.watts.WattsVisionHubCoordinator" - ) as mock_hub_class, - patch( - "homeassistant.components.watts.WattsVisionDeviceCoordinator" - ) as mock_device_coordinator_class, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation + assert mock_config_entry.state is ConfigEntryState.LOADED - mock_session_instance = AsyncMock() - mock_session_instance.token = { - "access_token": "test-access-token", - "refresh_token": "test-refresh-token", - "expires_at": 9999999999, - } - mock_session_instance.async_ensure_token_valid = AsyncMock() - mock_session.return_value = mock_session_instance + mock_watts_client.discover_devices.assert_called_once() - mock_auth_instance = AsyncMock() - mock_auth_class.return_value = mock_auth_instance - - mock_client_instance = AsyncMock() - mock_client_class.return_value = mock_client_instance - - # Set up hub coordinator mock - mock_hub = mock_hub_class.return_value - mock_hub.device_ids = ["device_123"] - mock_hub.data = {"device_123": mock_device} - mock_hub.async_config_entry_first_refresh = AsyncMock() + unload_result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() - # Set up device coordinator mock - mock_device_coord = mock_device_coordinator_class.return_value - mock_device_coord.async_set_updated_data = MagicMock() + assert unload_result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert result is True - assert config_entry.state is ConfigEntryState.LOADED - mock_hub.async_config_entry_first_refresh.assert_called_once() - mock_device_coord.async_set_updated_data.assert_called_once_with(mock_device) - # Test unload - unload_result = await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - assert unload_result is True - assert config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_setup_entry_auth_failed(hass: HomeAssistant) -> None: +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test setup with authentication failure.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": TEST_ACCESS_TOKEN, - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - }, - }, - ) - config_entry.add_to_hass(hass) + + mock_config_entry.add_to_hass(hass) with ( patch( @@ -140,33 +57,23 @@ async def test_setup_entry_auth_failed(hass: HomeAssistant) -> None: mock_session_instance.async_ensure_token_valid.side_effect = ( ClientResponseError(None, None, status=401, message="Unauthorized") ) - mock_session_instance.token = { - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - } + mock_session_instance.token = mock_config_entry.data["token"] mock_session.return_value = mock_session_instance - result = await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert result is False - assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_setup_entry_not_ready(hass: HomeAssistant) -> None: +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test setup when network is temporarily unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": TEST_ACCESS_TOKEN, - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - }, - }, - ) - config_entry.add_to_hass(hass) + + mock_config_entry.add_to_hass(hass) with ( patch( @@ -185,33 +92,27 @@ async def test_setup_entry_not_ready(hass: HomeAssistant) -> None: mock_session_instance.async_ensure_token_valid.side_effect = ClientError( "Connection timeout" ) - mock_session_instance.token = { - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - } + mock_session_instance.token = mock_config_entry.data["token"] mock_session.return_value = mock_session_instance - result = await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert result is False - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_entry_hub_coordinator_update_failed(hass: HomeAssistant) -> None: +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.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": TEST_ACCESS_TOKEN, - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - }, - }, - ) - config_entry.add_to_hass(hass) + + # Make discover_devices fail + mock_watts_client.discover_devices.side_effect = ConnectionError("API error") + + mock_config_entry.add_to_hass(hass) with ( patch( @@ -220,11 +121,11 @@ async def test_setup_entry_hub_coordinator_update_failed(hass: HomeAssistant) -> patch( "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" ) as mock_session, - patch("homeassistant.components.watts.WattsVisionClient") as mock_client_class, - patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, patch( - "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh" - ) as mock_hub_first_refresh, + "homeassistant.components.watts.WattsVisionClient", + return_value=mock_watts_client, + ), + patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, ): mock_implementation = AsyncMock() mock_implementation.client_id = "test-client-id" @@ -232,85 +133,15 @@ async def test_setup_entry_hub_coordinator_update_failed(hass: HomeAssistant) -> mock_get_implementation.return_value = mock_implementation mock_session_instance = AsyncMock() - mock_session_instance.token = { - "access_token": TEST_ACCESS_TOKEN, - "refresh_token": TEST_REFRESH_TOKEN, - "expires_at": TEST_EXPIRES_AT, - } + mock_session_instance.token = mock_config_entry.data["token"] mock_session_instance.async_ensure_token_valid = AsyncMock() mock_session.return_value = mock_session_instance mock_auth_instance = AsyncMock() mock_auth_class.return_value = mock_auth_instance - mock_client_instance = AsyncMock() - mock_client_class.return_value = mock_client_instance - - mock_hub_first_refresh.side_effect = UpdateFailed( - "Hub coordinator update failed" - ) - result = await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert result is False - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_unload_entry_success(hass: HomeAssistant) -> None: - """Test successful unload of entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - ) - config_entry.add_to_hass(hass) - - # Mock the runtime data with new structure - mock_client = AsyncMock(spec=WattsVisionClient) - mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_hub_coordinator = AsyncMock(spec=WattsVisionHubCoordinator) - mock_device_coordinator = AsyncMock(spec=WattsVisionDeviceCoordinator) - - config_entry.runtime_data = WattsVisionRuntimeData( - client=mock_client, - auth=mock_auth, - hub_coordinator=mock_hub_coordinator, - device_coordinators={"device_123": mock_device_coordinator}, - ) - - with patch( - "homeassistant.config_entries.ConfigEntries.async_unload_platforms", - return_value=True, - ): - result = await async_unload_entry(hass, config_entry) - - assert result is True - - -async def test_unload_entry_platform_unload_fails(hass: HomeAssistant) -> None: - """Test unload when platform unload fails.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - ) - config_entry.add_to_hass(hass) - - # Mock the runtime data - mock_client = AsyncMock(spec=WattsVisionClient) - mock_auth = AsyncMock(spec=WattsVisionAuth) - mock_hub_coordinator = AsyncMock(spec=WattsVisionHubCoordinator) - mock_device_coordinator = AsyncMock(spec=WattsVisionDeviceCoordinator) - - config_entry.runtime_data = WattsVisionRuntimeData( - client=mock_client, - auth=mock_auth, - hub_coordinator=mock_hub_coordinator, - device_coordinators={"device_123": mock_device_coordinator}, - ) - - with patch( - "homeassistant.config_entries.ConfigEntries.async_unload_platforms", - return_value=False, - ): - result = await async_unload_entry(hass, config_entry) - - assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From a878119349f27f77aadd2d97b715bc639727319d Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Mon, 20 Oct 2025 12:05:08 +0000 Subject: [PATCH 19/43] Update quality scale --- .../components/watts/quality_scale.yaml | 48 +++++++++++-------- homeassistant/generated/integrations.json | 12 ++--- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 76b8d347408bf3..5955b7e6131107 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -20,41 +20,51 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: + status: exempt + comment: Integration does not register custom actions. config-entry-unloading: todo - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo - integration-owner: todo - log-when-unavailable: todo + 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: todo reauthentication-flow: todo test-coverage: todo # Gold - devices: todo + devices: done diagnostics: todo - discovery-update-info: todo - discovery: todo - docs-data-update: todo - docs-examples: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Integration does not support discovery. + docs-data-update: done + docs-examples: done docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo + docs-supported-devices: done + docs-supported-functions: done docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: Integration uses OAuth2 authentication which is managed by application credentials. repair-issues: todo stale-devices: todo # Platinum - async-dependency: todo - inject-websession: todo + async-dependency: done + inject-websession: done strict-typing: todo diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6ca19803c9073f..61145fc61f17dc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7443,18 +7443,18 @@ "config_flow": true, "iot_class": "local_push" }, - "watts": { - "name": "Watts Vision +", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "watson_tts": { "name": "IBM Watson TTS", "integration_type": "hub", "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", From cc52bac9292ab8692c91c7fc8a1577e4a43be4ef Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 12 Nov 2025 16:04:29 +0000 Subject: [PATCH 20/43] Implemente config-entry-unloading by unsubscribe listeners --- homeassistant/components/watts/__init__.py | 3 +++ homeassistant/components/watts/coordinator.py | 4 +++- homeassistant/components/watts/quality_scale.yaml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 1fe5cbcbc0d098..ffc5cda21b175c 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -93,4 +93,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: WattsVisionConfigEntry ) -> bool: """Unload a config entry.""" + for device_coordinator in entry.runtime_data.device_coordinators.values(): + device_coordinator.unsubscribe_hub_listener() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index f0ab0076482761..d59e945ac77949 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -90,7 +90,9 @@ def __init__( self._fast_polling_until: datetime | None = None # Listen to hub coordinator updates - hub_coordinator.async_add_listener(self._handle_hub_update) + self.unsubscribe_hub_listener = hub_coordinator.async_add_listener( + self._handle_hub_update + ) def _handle_hub_update(self) -> None: """Handle updates from hub coordinator.""" diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 5955b7e6131107..12d0d5b32c0930 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -23,7 +23,7 @@ rules: action-exceptions: status: exempt comment: Integration does not register custom actions. - config-entry-unloading: todo + config-entry-unloading: done docs-configuration-parameters: status: exempt comment: Integration does not have configuration parameters. From 6f61e8664862b6253b1cdbf81793d5c721994238 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 12 Nov 2025 16:28:51 +0000 Subject: [PATCH 21/43] support parallel update --- homeassistant/components/watts/climate.py | 2 ++ homeassistant/components/watts/quality_scale.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 14e09bfe06b737..5213553b76f475 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -24,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 12d0d5b32c0930..55f544a554201b 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -31,7 +31,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo From 8305ba13ec20090614e13d43679f0bad71fab404 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 08:53:27 +0000 Subject: [PATCH 22/43] Support oauth reauthentication --- homeassistant/components/watts/config_flow.py | 25 ++- homeassistant/components/watts/coordinator.py | 48 ++++- .../components/watts/quality_scale.yaml | 2 +- homeassistant/components/watts/strings.json | 24 ++- tests/components/watts/test_config_flow.py | 202 +++++++++++++++--- 5 files changed, 262 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index c71e67528aa2a2..d0598fe06b35e0 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -1,11 +1,12 @@ """Config flow for Watts Vision integration.""" +from collections.abc import Mapping import logging from typing import Any from visionpluspython.auth import WattsVisionAuth -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -32,6 +33,20 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -42,6 +57,14 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="invalid_token") await self.async_set_unique_id(user_id) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=data, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index d59e945ac77949..3d02ee7fef86b1 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -6,10 +6,18 @@ import logging from visionpluspython.client import WattsVisionClient +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) from visionpluspython.models import Device from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, FAST_POLLING_INTERVAL, UPDATE_INTERVAL @@ -53,7 +61,22 @@ async def _async_update_data(self) -> dict[str, Device]: devices = await self.client.get_devices_report(device_ids) _LOGGER.debug("Updated %d devices", len(devices)) - except (ConnectionError, TimeoutError, ValueError) as err: + except WattsVisionAuthError as err: + _LOGGER.error("Authentication error during devices update: %s", err) + raise ConfigEntryAuthFailed( + f"Authentication failed during devices update: {err}" + ) from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + ConnectionError, + TimeoutError, + ) as err: + _LOGGER.error("Connection error during devices update: %s", err) + raise UpdateFailed( + f"Connection error during devices update: {err}" + ) from err + except (WattsVisionDeviceError, WattsVisionError, ValueError) as err: _LOGGER.error("API error during devices update: %s", err) raise UpdateFailed(f"API error during devices update: {err}") from err else: @@ -112,11 +135,28 @@ async def _async_update_data(self) -> Device: try: device = await self.client.get_device(self.device_id, refresh=True) - except (ConnectionError, TimeoutError, ValueError) as err: - _LOGGER.error("Failed to refresh device %s: %s", self.device_id, err) + except WattsVisionAuthError as err: + _LOGGER.error( + "Authentication error refreshing device %s: %s", self.device_id, err + ) + raise ConfigEntryAuthFailed( + f"Authentication failed for device {self.device_id}: {err}" + ) from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + ConnectionError, + TimeoutError, + ) as err: + _LOGGER.error( + "Connection error refreshing device %s: %s", self.device_id, err + ) raise UpdateFailed( - f"Failed to refresh device {self.device_id}: {err}" + f"Connection error for device {self.device_id}: {err}" ) from err + except (WattsVisionDeviceError, WattsVisionError, ValueError) as err: + _LOGGER.error("API error refreshing device %s: %s", self.device_id, err) + raise UpdateFailed(f"API error for device {self.device_id}: {err}") from err if not device: _LOGGER.error("Device %s not found during refresh", self.device_id) diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 55f544a554201b..55df09ff15dbfb 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -32,7 +32,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index 79421b6ae74f05..f22819d650d5d6 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -1,25 +1,31 @@ { "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, "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%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_successful": "Successfully reauthenticated.", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "invalid_token": "The provided access token is invalid." + "wrong_account": "The user credentials provided do not match this Watts Vision account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Watts Vision integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + } } } } diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 40defcc11242ea..b239fe0ab68464 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Watts Vision config flow.""" +from types import MappingProxyType from unittest.mock import AsyncMock, patch import pytest @@ -24,11 +25,11 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result.get("type") is FlowResultType.EXTERNAL_STEP assert "url" in result - assert OAUTH2_AUTHORIZE in result["url"] - assert "response_type=code" in result["url"] - assert "scope=" in result["url"] + 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, @@ -63,9 +64,9 @@ async def test_full_flow( ), ): result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Watts Vision +" - assert "token" in result2["data"] + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Watts Vision +" + assert "token" in result2.get("data", {}) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -106,8 +107,8 @@ async def test_invalid_token_flow( return_value=None, ): result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "invalid_token" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "invalid_token" @pytest.mark.usefixtures("current_request_with_host") @@ -138,8 +139,8 @@ async def test_oauth_error( ) result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "oauth_error" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "oauth_error" @pytest.mark.usefixtures("current_request_with_host") @@ -167,8 +168,8 @@ async def test_oauth_timeout( aioclient_mock.post(OAUTH2_TOKEN, exc=TimeoutError()) result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "oauth_timeout" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "oauth_timeout" @pytest.mark.usefixtures("current_request_with_host") @@ -196,8 +197,8 @@ async def test_oauth_invalid_response( aioclient_mock.post(OAUTH2_TOKEN, status=500, text="invalid json") result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "oauth_failed" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "oauth_failed" @pytest.mark.usefixtures("current_request_with_host") @@ -217,8 +218,8 @@ async def test_unique_config_entry( unique_id="user123", entry_id="test_entry", options={}, - discovery_keys={}, - subentries_data={}, + discovery_keys=MappingProxyType({}), + subentries_data=MappingProxyType({}), ) await hass.config_entries.async_add(mock_entry) @@ -230,7 +231,7 @@ async def test_unique_config_entry( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - if result["type"] is FlowResultType.EXTERNAL_STEP: + if result.get("type") is FlowResultType.EXTERNAL_STEP: state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -254,8 +255,8 @@ async def test_unique_config_entry( result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -304,14 +305,14 @@ async def test_unique_config_entry_full_flow( ), ): result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2.get("type") is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result3["type"] is FlowResultType.EXTERNAL_STEP + assert result3.get("type") is FlowResultType.EXTERNAL_STEP state2 = config_entry_oauth2_flow._encode_jwt( hass, @@ -338,6 +339,159 @@ async def test_unique_config_entry_full_flow( return_value="user123", ): result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "already_configured" + assert result4.get("type") is FlowResultType.ABORT + assert result4.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauthentication flow.""" + mock_entry = config_entries.ConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Watts Vision +", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "old-refresh-token", + "access_token": "old-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": 0, + }, + }, + source=config_entries.SOURCE_USER, + unique_id="user123", + entry_id="test_entry", + options={}, + discovery_keys=MappingProxyType({}), + subentries_data=MappingProxyType({}), + ) + await hass.config_entries.async_add(mock_entry) + + mock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + 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": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with ( + patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ), + patch( + "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", + return_value=AsyncMock(), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + assert mock_entry.data["token"]["refresh_token"] == "new-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauthentication with wrong account.""" + mock_entry = config_entries.ConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Watts Vision +", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "old-refresh-token", + "access_token": "old-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": 0, + }, + }, + source=config_entries.SOURCE_USER, + unique_id="user123", + entry_id="test_entry", + options={}, + discovery_keys=MappingProxyType({}), + subentries_data=MappingProxyType({}), + ) + await hass.config_entries.async_add(mock_entry) + + mock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + 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": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="different_user", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "wrong_account" From d1566c7d92f35392be8f718799fdd2231f38745e Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 13:32:20 +0000 Subject: [PATCH 23/43] Increase tests coverage --- homeassistant/components/watts/manifest.json | 2 +- .../components/watts/quality_scale.yaml | 2 +- tests/components/watts/test_climate.py | 22 ++++ tests/components/watts/test_init.py | 105 +++++++++++++++++- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 018573c77a2fbc..1eeb80e66f35cb 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["visionpluspython==1.0.1"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 55df09ff15dbfb..36abbde772be87 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -33,7 +33,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 0d9e2557c0c900..48e8daf492bed2 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -236,3 +236,25 @@ async def test_set_temperature_api_error( }, 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) + + mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode") + + with pytest.raises(HomeAssistantError, match="Error setting 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_init.py b/tests/components/watts/test_init.py index 20aed1608b7c6d..f468a0384dfdff 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -1,8 +1,16 @@ """Test the Watts Vision integration initialization.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError, ClientResponseError +import pytest +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -55,7 +63,7 @@ async def test_setup_entry_auth_failed( # Raise 401 error mock_session_instance = AsyncMock() mock_session_instance.async_ensure_token_valid.side_effect = ( - ClientResponseError(None, None, status=401, message="Unauthorized") + ClientResponseError(Mock(), Mock(), status=401, message="Unauthorized") ) mock_session_instance.token = mock_config_entry.data["token"] mock_session.return_value = mock_session_instance @@ -145,3 +153,96 @@ async def test_setup_entry_hub_coordinator_update_failed( assert result is False assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_server_error_5xx( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup when server returns error.""" + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + mock_session_instance = AsyncMock() + mock_session_instance.async_ensure_token_valid.side_effect = ( + ClientResponseError( + Mock(), Mock(), status=500, message="Internal Server Error" + ) + ) + mock_session_instance.token = mock_config_entry.data["token"] + mock_session.return_value = mock_session_instance + + 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.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) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ) as mock_get_implementation, + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session, + patch( + "homeassistant.components.watts.WattsVisionClient", + return_value=mock_watts_client, + ), + patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, + ): + mock_implementation = AsyncMock() + mock_implementation.client_id = "test-client-id" + mock_implementation.client_secret = "test-client-secret" + mock_get_implementation.return_value = mock_implementation + + mock_session_instance = AsyncMock() + mock_session_instance.token = mock_config_entry.data["token"] + mock_session_instance.async_ensure_token_valid = AsyncMock() + mock_session.return_value = mock_session_instance + + mock_auth_instance = AsyncMock() + mock_auth_class.return_value = mock_auth_instance + + 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 From 16541678aa9ebd54182fa3d620f17b997ffa6641 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 17:33:51 +0000 Subject: [PATCH 24/43] Use WattsVisionConfigEntry --- homeassistant/components/watts/__init__.py | 2 -- homeassistant/components/watts/coordinator.py | 13 +++++++++++-- homeassistant/components/watts/quality_scale.yaml | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index ffc5cda21b175c..7c8410c1973c00 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -39,8 +39,6 @@ class WattsVisionRuntimeData: async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: """Set up Watts Vision from a config entry.""" - _LOGGER.debug("Setting up Watts Vision integration") - implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 3d02ee7fef86b1..0d51c3f649954f 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING from visionpluspython.client import WattsVisionClient from visionpluspython.exceptions import ( @@ -22,6 +23,11 @@ from .const import DOMAIN, FAST_POLLING_INTERVAL, UPDATE_INTERVAL +if TYPE_CHECKING: + from . import WattsVisionRuntimeData + +type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + _LOGGER = logging.getLogger(__name__) @@ -29,7 +35,10 @@ class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Hub coordinator for bulk device discovery and updates.""" def __init__( - self, hass: HomeAssistant, client: WattsVisionClient, config_entry: ConfigEntry + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: WattsVisionConfigEntry, ) -> None: """Initialize the hub coordinator.""" super().__init__( @@ -95,7 +104,7 @@ def __init__( self, hass: HomeAssistant, client: WattsVisionClient, - config_entry: ConfigEntry, + config_entry: WattsVisionConfigEntry, hub_coordinator: WattsVisionHubCoordinator, device_id: str, ) -> None: diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 36abbde772be87..3eaee169ece7c7 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -55,7 +55,9 @@ rules: entity-category: done entity-device-class: done entity-disabled-by-default: done - entity-translations: todo + entity-translations: + status: exempt + comment: No entity required translations. exception-translations: todo icon-translations: todo reconfiguration-flow: From a21c8c89f067d635300366ad798e9d11053674f6 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 19:04:33 +0000 Subject: [PATCH 25/43] Use setup func for first discovery --- homeassistant/components/watts/__init__.py | 1 + homeassistant/components/watts/coordinator.py | 87 +++++++++---------- .../watts/snapshots/test_climate.ambr | 4 +- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 7c8410c1973c00..b7f9b7cf393284 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -65,6 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) client = WattsVisionClient(auth, session) hub_coordinator = WattsVisionHubCoordinator(hass, client, entry) + await hub_coordinator.async_setup() await hub_coordinator.async_config_entry_first_refresh() device_coordinators = {} diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 0d51c3f649954f..9d4f28fd1320b1 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, FAST_POLLING_INTERVAL, UPDATE_INTERVAL @@ -50,46 +50,50 @@ def __init__( ) self.client = client + async def async_setup(self) -> None: + """Set up the coordinator by discovering devices.""" + 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: + raise ConfigEntryNotReady("Failed to discover devices") from err + + devices = {device.device_id: device for device in devices_list} + _LOGGER.info("Initial discovery completed with %d devices", len(devices)) + self.async_set_updated_data(devices) + async def _async_update_data(self) -> dict[str, Device]: """Fetch data from Watts Vision API for all devices.""" - try: - if not self.data: - # First loading, discover devices - devices_list = await self.client.discover_devices() - devices = {device.device_id: device for device in devices_list} - _LOGGER.info( - "Initial discovery completed with %d devices", len(devices) - ) - else: - device_ids = list(self.data.keys()) - - if not device_ids: - _LOGGER.warning("No devices to update") - devices = self.data - else: - devices = await self.client.get_devices_report(device_ids) - _LOGGER.debug("Updated %d devices", len(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: - _LOGGER.error("Authentication error during devices update: %s", err) - raise ConfigEntryAuthFailed( - f"Authentication failed during devices update: {err}" - ) from err + raise ConfigEntryAuthFailed("Authentication failed") from err except ( WattsVisionConnectionError, WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, ConnectionError, TimeoutError, + ValueError, ) as err: - _LOGGER.error("Connection error during devices update: %s", err) - raise UpdateFailed( - f"Connection error during devices update: {err}" - ) from err - except (WattsVisionDeviceError, WattsVisionError, ValueError) as err: - _LOGGER.error("API error during devices update: %s", err) - raise UpdateFailed(f"API error during devices update: {err}") from err - else: - return devices + raise UpdateFailed("Failed to update devices") from err + + _LOGGER.debug("Updated %d devices", len(devices)) + return devices @property def device_ids(self) -> list[str]: @@ -145,30 +149,19 @@ async def _async_update_data(self) -> Device: try: device = await self.client.get_device(self.device_id, refresh=True) except WattsVisionAuthError as err: - _LOGGER.error( - "Authentication error refreshing device %s: %s", self.device_id, err - ) - raise ConfigEntryAuthFailed( - f"Authentication failed for device {self.device_id}: {err}" - ) from err + raise ConfigEntryAuthFailed("Authentication failed") from err except ( WattsVisionConnectionError, WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, ConnectionError, TimeoutError, + ValueError, ) as err: - _LOGGER.error( - "Connection error refreshing device %s: %s", self.device_id, err - ) - raise UpdateFailed( - f"Connection error for device {self.device_id}: {err}" - ) from err - except (WattsVisionDeviceError, WattsVisionError, ValueError) as err: - _LOGGER.error("API error refreshing device %s: %s", self.device_id, err) - raise UpdateFailed(f"API error for device {self.device_id}: {err}") from err + raise UpdateFailed(f"Failed to refresh device {self.device_id}") from err if not device: - _LOGGER.error("Device %s not found during refresh", self.device_id) raise UpdateFailed(f"Device {self.device_id} not found") _LOGGER.debug("Refreshed device %s", self.device_id) diff --git a/tests/components/watts/snapshots/test_climate.ambr b/tests/components/watts/snapshots/test_climate.ambr index 88417d17cbbfbd..566b947b82dbee 100644 --- a/tests/components/watts/snapshots/test_climate.ambr +++ b/tests/components/watts/snapshots/test_climate.ambr @@ -45,7 +45,7 @@ # name: test_entities[climate.bedroom_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 19.0, + 'current_temperature': 19.2, 'friendly_name': 'Bedroom Thermostat', 'hvac_modes': list([ , @@ -111,7 +111,7 @@ # name: test_entities[climate.living_room_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 20.5, + 'current_temperature': 20.8, 'friendly_name': 'Living Room Thermostat', 'hvac_modes': list([ , From b5ca70ea3a69b1b875cda1a5d000ccb50d0d2de3 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 19:13:33 +0000 Subject: [PATCH 26/43] Remove unnecessary condition and type checking fix --- homeassistant/components/watts/coordinator.py | 2 +- homeassistant/components/watts/entity.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 9d4f28fd1320b1..8ea64a60972d99 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from . import WattsVisionRuntimeData -type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index 7e7fa828cc0f39..c099e4159ef0ae 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -24,15 +24,13 @@ def __init__( super().__init__(coordinator, context=device_id) self.device_id = device_id self._attr_unique_id = device_id - - if self.device: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.device_id)}, - name=self.device.device_name, - manufacturer="Watts", - model=f"Vision+ {self.device.device_type}", - suggested_area=self.device.room_name, - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + name=self.device.device_name, + manufacturer="Watts", + model=f"Vision+ {self.device.device_type}", + suggested_area=self.device.room_name, + ) @property def device(self) -> Device: From ec2259354cda234f24fb48a29c9437b6c1956812 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 19:27:19 +0000 Subject: [PATCH 27/43] Simplify tests --- .../components/watts/quality_scale.yaml | 4 +- tests/components/watts/__init__.py | 39 +------------------ tests/components/watts/test_climate.py | 18 ++++----- tests/components/watts/test_init.py | 2 +- 4 files changed, 13 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 3eaee169ece7c7..561e9ffa5be657 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -20,9 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not register custom actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/tests/components/watts/__init__.py b/tests/components/watts/__init__.py index fad150d642b6c2..5ce8066f60e583 100644 --- a/tests/components/watts/__init__.py +++ b/tests/components/watts/__init__.py @@ -1,7 +1,5 @@ """Tests for the Watts Vision integration.""" -from unittest.mock import AsyncMock, patch - from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -10,41 +8,8 @@ async def setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_client: AsyncMock, ) -> None: """Set up the Watts Vision integration for testing.""" config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - patch( - "homeassistant.components.watts.WattsVisionClient", - return_value=mock_client, - ), - patch( - "homeassistant.components.watts.WattsVisionAuth", - ) as mock_auth_class, - ): - # Mock OAuth2 implementation - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - # Mock OAuth2 session - mock_session_instance = AsyncMock() - mock_session_instance.token = config_entry.data["token"] - mock_session_instance.async_ensure_token_valid = AsyncMock() - mock_session.return_value = mock_session_instance - - # Mock auth - mock_auth_instance = AsyncMock() - mock_auth_class.return_value = mock_auth_instance - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 48e8daf492bed2..601efea66fdf22 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -35,7 +35,7 @@ async def test_entities( ) -> None: """Test the climate entities.""" with patch("homeassistant.components.watts.PLATFORMS", [Platform.CLIMATE]): - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -46,7 +46,7 @@ async def test_set_temperature( mock_config_entry: MockConfigEntry, ) -> None: """Test setting temperature.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) state = hass.states.get("climate.living_room_thermostat") assert state is not None @@ -74,7 +74,7 @@ async def test_set_temperature_triggers_fast_polling( freezer: FrozenDateTimeFactory, ) -> None: """Test that setting temperature triggers fast polling.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) # Trigger fast polling await hass.services.async_call( @@ -106,7 +106,7 @@ async def test_fast_polling_stops_after_duration( freezer: FrozenDateTimeFactory, ) -> None: """Test that fast polling stops after the duration expires.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) # Trigger fast polling await hass.services.async_call( @@ -152,7 +152,7 @@ async def test_set_hvac_mode_heat( mock_config_entry: MockConfigEntry, ) -> None: """Test setting HVAC mode to heat.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) await hass.services.async_call( CLIMATE_DOMAIN, @@ -175,7 +175,7 @@ async def test_set_hvac_mode_auto( mock_config_entry: MockConfigEntry, ) -> None: """Test setting HVAC mode to auto.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) await hass.services.async_call( CLIMATE_DOMAIN, @@ -198,7 +198,7 @@ async def test_set_hvac_mode_off( mock_config_entry: MockConfigEntry, ) -> None: """Test setting HVAC mode to off.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) await hass.services.async_call( CLIMATE_DOMAIN, @@ -221,7 +221,7 @@ async def test_set_temperature_api_error( mock_config_entry: MockConfigEntry, ) -> None: """Test error handling when setting temperature fails.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) # Make the API call fail mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error") @@ -244,7 +244,7 @@ async def test_set_hvac_mode_value_error( mock_config_entry: MockConfigEntry, ) -> None: """Test error handling when setting mode fails.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode") diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index f468a0384dfdff..5b5b2c1d438a59 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -26,7 +26,7 @@ async def test_setup_entry_success( mock_config_entry: MockConfigEntry, ) -> None: """Test successful setup and unload of entry.""" - await setup_integration(hass, mock_config_entry, mock_watts_client) + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED From a6ba3808e072de2106be054fee7476686dfbc7ec Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 19:33:19 +0000 Subject: [PATCH 28/43] remove unused tests patch --- tests/components/watts/test_init.py | 68 ++++------------------------- 1 file changed, 8 insertions(+), 60 deletions(-) diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index 5b5b2c1d438a59..de50c2a635449c 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -122,37 +122,11 @@ async def test_setup_entry_hub_coordinator_update_failed( mock_config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - patch( - "homeassistant.components.watts.WattsVisionClient", - return_value=mock_watts_client, - ), - patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - mock_session_instance = AsyncMock() - mock_session_instance.token = mock_config_entry.data["token"] - mock_session_instance.async_ensure_token_valid = AsyncMock() - mock_session.return_value = mock_session_instance - - mock_auth_instance = AsyncMock() - mock_auth_class.return_value = mock_auth_instance - - result = await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + 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 + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_entry_server_error_5xx( @@ -215,34 +189,8 @@ async def test_setup_entry_discover_devices_errors( mock_config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - patch( - "homeassistant.components.watts.WattsVisionClient", - return_value=mock_watts_client, - ), - patch("homeassistant.components.watts.WattsVisionAuth") as mock_auth_class, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - mock_session_instance = AsyncMock() - mock_session_instance.token = mock_config_entry.data["token"] - mock_session_instance.async_ensure_token_valid = AsyncMock() - mock_session.return_value = mock_session_instance - - mock_auth_instance = AsyncMock() - mock_auth_class.return_value = mock_auth_instance - - result = await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + 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 + assert result is False + assert mock_config_entry.state is expected_state From 130ee123f3aea36f3b2346a4786bb45b30c8a9d8 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 13 Nov 2025 19:38:31 +0000 Subject: [PATCH 29/43] use ha json funcs --- tests/components/watts/conftest.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index d5e4d73355e7b7..c1a0816c3b8efd 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -1,7 +1,6 @@ """Fixtures for the Watts integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, patch import pytest @@ -15,7 +14,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) CLIENT_ID = "test_client_id" CLIENT_SECRET = "test_client_secret" @@ -56,9 +59,9 @@ def mock_watts_client() -> Generator[AsyncMock]: ) as mock_client_class: client = mock_client_class.return_value - discover_data = json.loads(load_fixture("discover_devices.json", DOMAIN)) - device_report_data = json.loads(load_fixture("device_report.json", DOMAIN)) - device_detail_data = json.loads(load_fixture("device_detail.json", DOMAIN)) + 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) for device_data in discover_data From 224db5f4ce1b941a87f7347e0f65939e2c98fce5 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Tue, 9 Dec 2025 18:24:50 +0000 Subject: [PATCH 30/43] Improve tests and mocking --- .../components/watts/quality_scale.yaml | 6 +- tests/components/watts/conftest.py | 8 +- tests/components/watts/test_config_flow.py | 202 ++++-------------- tests/components/watts/test_init.py | 162 +++++++------- 4 files changed, 122 insertions(+), 256 deletions(-) diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 561e9ffa5be657..3c283de21397e7 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -41,7 +41,7 @@ rules: comment: Integration does not support discovery. discovery: status: exempt - comment: Integration does not support discovery. + comment: Integration is a cloud service and does not support discovery. docs-data-update: done docs-examples: done docs-known-limitations: todo @@ -58,9 +58,7 @@ rules: comment: No entity required translations. exception-translations: todo icon-translations: todo - reconfiguration-flow: - status: exempt - comment: Integration uses OAuth2 authentication which is managed by application credentials. + reconfiguration-flow: todo repair-issues: todo stale-devices: todo diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index c1a0816c3b8efd..234ffe2afab671 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -64,19 +64,21 @@ def mock_watts_client() -> Generator[AsyncMock]: device_detail_data = load_json_object_fixture("device_detail.json", DOMAIN) discovered_devices = [ - create_device_from_data(device_data) for device_data in discover_data + create_device_from_data(device_data) + for device_data in discover_data + if isinstance(device_data, dict) ] device_report = { device_id: create_device_from_data(device_data) for device_id, device_data in device_report_data.items() + if isinstance(device_data, dict) } + assert isinstance(device_detail_data, dict) device_detail = create_device_from_data(device_detail_data) client.discover_devices.return_value = discovered_devices client.get_devices_report.return_value = device_report client.get_device.return_value = device_detail - client.set_thermostat_temperature = AsyncMock() - client.set_thermostat_mode = AsyncMock() yield client diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index b239fe0ab68464..265c0ddc669777 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Watts Vision config flow.""" -from types import MappingProxyType -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -11,11 +10,12 @@ 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") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -53,22 +53,18 @@ async def test_full_flow( }, ) - with ( - patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", - return_value="user123", - ), - patch( - "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", - return_value=AsyncMock(), - ), + with patch( + "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", ): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Watts Vision +" - assert "token" in result2.get("data", {}) + 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( @@ -103,12 +99,12 @@ async def test_invalid_token_flow( ) with patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", return_value=None, ): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "invalid_token" + 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") @@ -138,9 +134,9 @@ async def test_oauth_error( json={"error": "invalid_grant"}, ) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "oauth_error" + 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") @@ -167,9 +163,9 @@ async def test_oauth_timeout( aioclient_mock.post(OAUTH2_TOKEN, exc=TimeoutError()) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "oauth_timeout" + 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") @@ -196,9 +192,9 @@ async def test_oauth_invalid_response( aioclient_mock.post(OAUTH2_TOKEN, status=500, text="invalid json") - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "oauth_failed" + 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") @@ -208,23 +204,14 @@ async def test_unique_config_entry( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that duplicate config entries are not allowed.""" - mock_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + mock_config_entry = MockConfigEntry( domain=DOMAIN, - title="Watts Vision +", - data={"token": {"refresh_token": "mock-refresh-token"}}, - source=config_entries.SOURCE_USER, unique_id="user123", - entry_id="test_entry", - options={}, - discovery_keys=MappingProxyType({}), - subentries_data=MappingProxyType({}), ) - await hass.config_entries.async_add(mock_entry) + mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", return_value="user123", ): result = await hass.config_entries.flow.async_init( @@ -261,101 +248,16 @@ async def test_unique_config_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 -@pytest.mark.usefixtures("current_request_with_host") -async def test_unique_config_entry_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test that a full flow after an existing entry aborts due to uniqueness.""" - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - - 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", - ), - patch( - "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", - return_value=AsyncMock(), - ), - ): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - result3 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result3.get("type") is FlowResultType.EXTERNAL_STEP - - state2 = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result3["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - resp2 = await client.get(f"/auth/external/callback?code=efgh&state={state2}") - assert resp2.status == 200 - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token-2", - "access_token": "mock-access-token-2", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - with patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", - return_value="user123", - ): - result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - assert result4.get("type") is FlowResultType.ABORT - assert result4.get("reason") == "already_configured" - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_reauth_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, ) -> None: """Test reauthentication flow.""" - mock_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + mock_config_entry = MockConfigEntry( domain=DOMAIN, - title="Watts Vision +", + unique_id="user123", data={ "auth_implementation": DOMAIN, "token": { @@ -366,16 +268,10 @@ async def test_reauth_flow( "expires_at": 0, }, }, - source=config_entries.SOURCE_USER, - unique_id="user123", - entry_id="test_entry", - options={}, - discovery_keys=MappingProxyType({}), - subentries_data=MappingProxyType({}), ) - await hass.config_entries.async_add(mock_entry) + mock_config_entry.add_to_hass(hass) - mock_entry.async_start_reauth(hass) + mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -406,22 +302,16 @@ async def test_reauth_flow( }, ) - with ( - patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", - return_value="user123", - ), - patch( - "homeassistant.components.watts.WattsVisionHubCoordinator.async_config_entry_first_refresh", - return_value=AsyncMock(), - ), + with patch( + "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "reauth_successful" - assert mock_entry.data["token"]["refresh_token"] == "new-refresh-token" + assert mock_config_entry.data["token"]["refresh_token"] == "new-refresh-token" @pytest.mark.usefixtures("current_request_with_host") @@ -431,11 +321,9 @@ async def test_reauth_wrong_account( aioclient_mock: AiohttpClientMocker, ) -> None: """Test reauthentication with wrong account.""" - mock_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + mock_config_entry = MockConfigEntry( domain=DOMAIN, - title="Watts Vision +", + unique_id="user123", data={ "auth_implementation": DOMAIN, "token": { @@ -446,16 +334,10 @@ async def test_reauth_wrong_account( "expires_at": 0, }, }, - source=config_entries.SOURCE_USER, - unique_id="user123", - entry_id="test_entry", - options={}, - discovery_keys=MappingProxyType({}), - subentries_data=MappingProxyType({}), ) - await hass.config_entries.async_add(mock_entry) + mock_config_entry.add_to_hass(hass) - mock_entry.async_start_reauth(hass) + mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -487,7 +369,7 @@ async def test_reauth_wrong_account( ) with patch( - "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", return_value="different_user", ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index de50c2a635449c..67a5ff4f055aba 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -1,8 +1,8 @@ """Test the Watts Vision integration initialization.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError import pytest from visionpluspython.exceptions import ( WattsVisionAuthError, @@ -12,12 +12,14 @@ WattsVisionTimeoutError, ) +from homeassistant.components.watts.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import setup_integration from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker async def test_setup_entry_success( @@ -39,75 +41,64 @@ async def test_setup_entry_success( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("setup_credentials") async def test_setup_entry_auth_failed( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + 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() - mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - # Raise 401 error - mock_session_instance = AsyncMock() - mock_session_instance.async_ensure_token_valid.side_effect = ( - ClientResponseError(Mock(), Mock(), status=401, message="Unauthorized") - ) - mock_session_instance.token = mock_config_entry.data["token"] - mock_session.return_value = mock_session_instance - - 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_ERROR + 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, - mock_config_entry: MockConfigEntry, + 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() - mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - mock_session_instance = AsyncMock() - mock_session_instance.async_ensure_token_valid.side_effect = ClientError( - "Connection timeout" - ) - mock_session_instance.token = mock_config_entry.data["token"] - mock_session.return_value = mock_session_instance - - 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 + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_entry_hub_coordinator_update_failed( @@ -129,41 +120,34 @@ async def test_setup_entry_hub_coordinator_update_failed( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_credentials") async def test_setup_entry_server_error_5xx( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + 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() - mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ) as mock_get_implementation, - patch( - "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" - ) as mock_session, - ): - mock_implementation = AsyncMock() - mock_implementation.client_id = "test-client-id" - mock_implementation.client_secret = "test-client-secret" - mock_get_implementation.return_value = mock_implementation - - mock_session_instance = AsyncMock() - mock_session_instance.async_ensure_token_valid.side_effect = ( - ClientResponseError( - Mock(), Mock(), status=500, message="Internal Server Error" - ) - ) - mock_session_instance.token = mock_config_entry.data["token"] - mock_session.return_value = mock_session_instance - - 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 + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.parametrize( From 271f3a8c1ca1bcde2a3e03b5eafb98ec662a53f4 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 10 Dec 2025 15:50:41 +0000 Subject: [PATCH 31/43] Implement oauth ConfigEntryNotReady --- homeassistant/components/watts/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index b7f9b7cf393284..b6468b7d1442aa 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -39,11 +39,16 @@ class WattsVisionRuntimeData: async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: """Set up Watts Vision from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, 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) From bdafd339febbc321e34b85b3ba70e2a078736e2c Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 10 Dec 2025 16:52:48 +0000 Subject: [PATCH 32/43] Add diagnostics support --- homeassistant/components/watts/diagnostics.py | 94 +++++++++++++ .../components/watts/quality_scale.yaml | 2 +- tests/components/watts/conftest.py | 4 + .../watts/snapshots/test_diagnostics.ambr | 123 ++++++++++++++++++ tests/components/watts/test_diagnostics.py | 57 ++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/watts/diagnostics.py create mode 100644 tests/components/watts/snapshots/test_diagnostics.ambr create mode 100644 tests/components/watts/test_diagnostics.py diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py new file mode 100644 index 00000000000000..c77e8081063423 --- /dev/null +++ b/homeassistant/components/watts/diagnostics.py @@ -0,0 +1,94 @@ +"""Diagnostics support for Watts Vision+.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import WattsVisionConfigEntry + +TO_REDACT = { + "access_token", + "refresh_token", + "id_token", + "profile_info", +} + + +def _get_coordinator_diagnostics(coordinator: Any) -> dict[str, Any]: + """Extract diagnostics from a coordinator.""" + return { + "last_update_success": coordinator.last_update_success, + "update_interval": ( + coordinator.update_interval.total_seconds() + if coordinator.update_interval + else None + ), + "last_exception": ( + str(coordinator.last_exception) if coordinator.last_exception else None + ), + } + + +def _device_to_dict(device: Any) -> dict[str, Any]: + """Convert Device object to dict for diagnostics.""" + if not (is_dataclass(device) and not isinstance(device, type)): + raise TypeError("Expected dataclass instance") + return asdict(device) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WattsVisionConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data + hub_coordinator = runtime_data.hub_coordinator + + devices_diagnostics: dict[str, Any] = {} + for device_id, device in hub_coordinator.data.items(): + device_coordinator = runtime_data.device_coordinators.get(device_id) + + device_data = _device_to_dict(device) + + if device_coordinator: + device_data["coordinator"] = _get_coordinator_diagnostics( + device_coordinator + ) + + devices_diagnostics[device_id] = device_data + + hub_diagnostics = _get_coordinator_diagnostics(hub_coordinator) + hub_diagnostics["device_count"] = len(hub_coordinator.data) + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "hub_coordinator": hub_diagnostics, + "devices": devices_diagnostics, + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: WattsVisionConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + runtime_data = entry.runtime_data + hub_coordinator = runtime_data.hub_coordinator + + device_id = next(iter(device.identifiers))[1] + + device_data = hub_coordinator.data.get(device_id) + if not device_data: + return {"error": "Device not found in coordinator data"} + + device_coordinator = runtime_data.device_coordinators.get(device_id) + + diagnostics = _device_to_dict(device_data) + + if device_coordinator: + diagnostics["coordinator"] = _get_coordinator_diagnostics(device_coordinator) + + return diagnostics diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 3c283de21397e7..8a9552c915cd6e 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -35,7 +35,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Integration does not support discovery. diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index 234ffe2afab671..ac3d1dd650c87b 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -25,6 +25,8 @@ TEST_DEVICE_ID = "test-device-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 @@ -95,6 +97,8 @@ def mock_config_entry() -> MockConfigEntry: "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, }, }, diff --git a/tests/components/watts/snapshots/test_diagnostics.ambr b/tests/components/watts/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..dfa14bb4d2b358 --- /dev/null +++ b/tests/components/watts/snapshots/test_diagnostics.ambr @@ -0,0 +1,123 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'devices': dict({ + 'thermostat_123': dict({ + 'available_thermostat_modes': list([ + 'Program', + 'Eco', + 'Comfort', + 'Off', + 'Defrost', + 'Timer', + ]), + 'coordinator': dict({ + 'last_exception': None, + 'last_update_success': True, + 'update_interval': None, + }), + 'current_temperature': 20.8, + 'device_id': 'thermostat_123', + 'device_name': 'Living Room Thermostat', + 'device_type': 'thermostat', + 'interface': 'homeassistant.components.THERMOSTAT', + 'is_online': True, + 'max_allowed_temperature': 30.0, + 'min_allowed_temperature': 5.0, + 'room_name': 'Living Room', + 'setpoint': 22.0, + 'temperature_unit': 'C', + 'thermostat_mode': 'Comfort', + }), + 'thermostat_456': dict({ + 'available_thermostat_modes': list([ + 'Program', + 'Eco', + 'Comfort', + 'Off', + ]), + 'coordinator': dict({ + 'last_exception': None, + 'last_update_success': True, + 'update_interval': None, + }), + 'current_temperature': 19.2, + 'device_id': 'thermostat_456', + 'device_name': 'Bedroom Thermostat', + 'device_type': 'thermostat', + 'interface': 'homeassistant.components.THERMOSTAT', + 'is_online': True, + 'max_allowed_temperature': 30.0, + 'min_allowed_temperature': 5.0, + 'room_name': 'Bedroom', + 'setpoint': 21.0, + 'temperature_unit': 'C', + 'thermostat_mode': 'Program', + }), + }), + 'entry': dict({ + 'data': dict({ + 'auth_implementation': 'watts', + 'device_id': 'test-device-id', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_at': 9999999999, + 'id_token': '**REDACTED**', + 'profile_info': '**REDACTED**', + 'refresh_token': '**REDACTED**', + }), + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'watts', + 'entry_id': '01J0BC4QM2YBRP6H5G933CETI8', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Watts Vision', + 'unique_id': 'test-device-id', + 'version': 1, + }), + 'hub_coordinator': dict({ + 'device_count': 2, + 'last_exception': None, + 'last_update_success': True, + 'update_interval': 30.0, + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'available_thermostat_modes': list([ + 'Program', + 'Eco', + 'Comfort', + 'Off', + 'Defrost', + 'Timer', + ]), + 'coordinator': dict({ + 'last_exception': None, + 'last_update_success': True, + 'update_interval': None, + }), + 'current_temperature': 20.8, + 'device_id': 'thermostat_123', + 'device_name': 'Living Room Thermostat', + 'device_type': 'thermostat', + 'interface': 'homeassistant.components.THERMOSTAT', + 'is_online': True, + 'max_allowed_temperature': 30.0, + 'min_allowed_temperature': 5.0, + 'room_name': 'Living Room', + 'setpoint': 22.0, + 'temperature_unit': 'C', + 'thermostat_mode': 'Comfort', + }) +# --- diff --git a/tests/components/watts/test_diagnostics.py b/tests/components/watts/test_diagnostics.py new file mode 100644 index 00000000000000..27161e5a5aadd7 --- /dev/null +++ b/tests/components/watts/test_diagnostics.py @@ -0,0 +1,57 @@ +"""Tests for Watts Vision diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.watts.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at")) + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_123")}) + assert device is not None + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert result == snapshot From 171a32d55291e41a863dc6bfc5799b4afd3314b6 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Mon, 15 Dec 2025 16:52:02 +0000 Subject: [PATCH 33/43] Dynamic and stale devices --- homeassistant/components/watts/__init__.py | 68 ++++++++++++++++++- homeassistant/components/watts/climate.py | 43 ++++++++++-- homeassistant/components/watts/const.py | 6 +- homeassistant/components/watts/coordinator.py | 67 ++++++++++++++++-- .../components/watts/quality_scale.yaml | 4 +- 5 files changed, 172 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index b6468b7d1442aa..009b0bd156eaf8 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -12,10 +12,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +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, + device_registry as dr, +) +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN from .coordinator import WattsVisionDeviceCoordinator, WattsVisionHubCoordinator _LOGGER = logging.getLogger(__name__) @@ -36,6 +42,52 @@ class WattsVisionRuntimeData: type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] +@callback +def _handle_new_devices( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, +) -> None: + """Check for new devices and create coordinators.""" + current_device_ids = set(hub_coordinator.data.keys()) + known_device_ids = set(entry.runtime_data.device_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) + + device_coordinators = entry.runtime_data.device_coordinators + client = entry.runtime_data.client + + for device_id in new_device_ids: + device_coordinator = WattsVisionDeviceCoordinator( + hass, client, entry, hub_coordinator, device_id + ) + device_coordinator.async_set_updated_data(hub_coordinator.data[device_id]) + device_coordinators[device_id] = device_coordinator + + _LOGGER.debug("Created coordinator for device %s", device_id) + + async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device") + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: WattsVisionConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + # Allow removal if device is not in coordinator data + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.hub_coordinator.data + ) + + async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: """Set up Watts Vision from a config entry.""" @@ -73,6 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) await hub_coordinator.async_setup() await hub_coordinator.async_config_entry_first_refresh() + # Stale device tracking + hub_coordinator.previous_devices = set(hub_coordinator.data.keys()) + device_coordinators = {} for device_id in hub_coordinator.device_ids: device_coordinator = WattsVisionDeviceCoordinator( @@ -90,6 +145,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) 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_devices(hass, entry, hub_coordinator) + ) + ) + return True diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 5213553b76f475..6b6feed80c8a1f 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -13,12 +13,13 @@ HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant +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 HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC +from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC from .coordinator import WattsVisionDeviceCoordinator from .entity import WattsVisionEntity @@ -35,10 +36,42 @@ async def async_setup_entry( """Set up Watts Vision climate entities from a config entry.""" device_coordinators = entry.runtime_data.device_coordinators + known_device_ids: set[str] = set() - async_add_entities( - WattsVisionClimate(device_coordinator, device_coordinator.data) - for device_coordinator in device_coordinators.values() + @callback + def _check_new_devices() -> None: + """Check for new devices.""" + current_device_ids = set(device_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 device(s)", + len(new_device_ids), + ) + + new_entities = [ + WattsVisionClimate( + device_coordinators[device_id], + device_coordinators[device_id].data, + ) + for device_id in new_device_ids + ] + + known_device_ids.update(new_device_ids) + async_add_entities(new_entities) + + _check_new_devices() + + # Listen for new devices + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{entry.entry_id}_new_device", + _check_new_devices, + ) ) diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index 60cd0d6ca01481..8434daca11d533 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -15,8 +15,10 @@ "https://visionlogin.onmicrosoft.com/homeassistant-api/homeassistant.read", ] -UPDATE_INTERVAL = 30 -FAST_POLLING_INTERVAL = 5 +# 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 diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 8ea64a60972d99..9d49811830f6ac 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -19,9 +19,15 @@ 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 DOMAIN, FAST_POLLING_INTERVAL, UPDATE_INTERVAL +from .const import ( + DISCOVERY_INTERVAL_MINUTES, + DOMAIN, + FAST_POLLING_INTERVAL_SECONDS, + UPDATE_INTERVAL_SECONDS, +) if TYPE_CHECKING: from . import WattsVisionRuntimeData @@ -45,10 +51,12 @@ def __init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + 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_setup(self) -> None: """Set up the coordinator by discovering devices.""" @@ -71,8 +79,44 @@ async def async_setup(self) -> None: _LOGGER.info("Initial discovery completed with %d devices", len(devices)) self.async_set_updated_data(devices) + self._last_discovery = datetime.now() + async def _async_update_data(self) -> dict[str, Device]: - """Fetch data from Watts Vision API for all devices.""" + """Fetch data and periodic device discovery.""" + now = datetime.now() + + if self._last_discovery is None or now - self._last_discovery >= timedelta( + minutes=DISCOVERY_INTERVAL_MINUTES + ): + try: + devices_list = await self.client.discover_devices() + except WattsVisionAuthError as err: + _LOGGER.warning("Periodic discovery failed with auth error: %s", err) + raise ConfigEntryAuthFailed("Authentication failed") from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as 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 {} @@ -95,6 +139,21 @@ async def _async_update_data(self) -> dict[str, Device]: _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.""" @@ -170,7 +229,7 @@ async def _async_update_data(self) -> 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) + 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/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 8a9552c915cd6e..00faf76fe10f1e 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -49,7 +49,7 @@ rules: docs-supported-functions: done docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -60,7 +60,7 @@ rules: icon-translations: todo reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: done # Platinum async-dependency: done From add64e46b6959783526feeef4a0e0c03c4453c6b Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 17 Dec 2025 14:41:37 +0000 Subject: [PATCH 34/43] Add strict typing support --- .strict-typing | 1 + homeassistant/components/watts/__init__.py | 54 ++++++++++++------- homeassistant/components/watts/climate.py | 45 ++++++++-------- homeassistant/components/watts/coordinator.py | 30 +++++++---- homeassistant/components/watts/diagnostics.py | 23 +++++--- homeassistant/components/watts/entity.py | 24 ++++----- homeassistant/components/watts/manifest.json | 2 +- .../components/watts/quality_scale.yaml | 2 +- mypy.ini | 10 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- visionpluspython | 1 + 12 files changed, 122 insertions(+), 74 deletions(-) create mode 160000 visionpluspython diff --git a/.strict-typing b/.strict-typing index ac0c8c38df5e34..91d91103c91669 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/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 009b0bd156eaf8..6fdf85d3a44a45 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -9,6 +9,7 @@ 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 @@ -22,7 +23,11 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN -from .coordinator import WattsVisionDeviceCoordinator, WattsVisionHubCoordinator +from .coordinator import ( + WattsVisionHubCoordinator, + WattsVisionThermostatCoordinator, + WattsVisionThermostatData, +) _LOGGER = logging.getLogger(__name__) @@ -35,7 +40,7 @@ class WattsVisionRuntimeData: auth: WattsVisionAuth hub_coordinator: WattsVisionHubCoordinator - device_coordinators: dict[str, WattsVisionDeviceCoordinator] + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] client: WattsVisionClient @@ -43,14 +48,15 @@ class WattsVisionRuntimeData: @callback -def _handle_new_devices( +def _handle_new_thermostats( hass: HomeAssistant, entry: WattsVisionConfigEntry, hub_coordinator: WattsVisionHubCoordinator, ) -> None: - """Check for new devices and create coordinators.""" + """Check for new thermostat devices and create coordinators.""" + current_device_ids = set(hub_coordinator.data.keys()) - known_device_ids = set(entry.runtime_data.device_coordinators.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: @@ -58,17 +64,23 @@ def _handle_new_devices( _LOGGER.info("Discovered %d new device(s): %s", len(new_device_ids), new_device_ids) - device_coordinators = entry.runtime_data.device_coordinators + thermostat_coordinators = entry.runtime_data.thermostat_coordinators client = entry.runtime_data.client for device_id in new_device_ids: - device_coordinator = WattsVisionDeviceCoordinator( + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( hass, client, entry, hub_coordinator, device_id ) - device_coordinator.async_set_updated_data(hub_coordinator.data[device_id]) - device_coordinators[device_id] = device_coordinator + thermostat_coordinator.async_set_updated_data( + WattsVisionThermostatData(thermostat=device) + ) + thermostat_coordinators[device_id] = thermostat_coordinator - _LOGGER.debug("Created coordinator for device %s", device_id) + _LOGGER.debug("Created thermostat coordinator for device %s", device_id) async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device") @@ -128,18 +140,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) # Stale device tracking hub_coordinator.previous_devices = set(hub_coordinator.data.keys()) - device_coordinators = {} + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] = {} for device_id in hub_coordinator.device_ids: - device_coordinator = WattsVisionDeviceCoordinator( + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( hass, client, entry, hub_coordinator, device_id ) - device_coordinator.async_set_updated_data(hub_coordinator.data[device_id]) - device_coordinators[device_id] = device_coordinator + 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, - device_coordinators=device_coordinators, + thermostat_coordinators=thermostat_coordinators, client=client, ) @@ -148,7 +166,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) # Listener for dynamic device detection entry.async_on_unload( hub_coordinator.async_add_listener( - lambda: _handle_new_devices(hass, entry, hub_coordinator) + lambda: _handle_new_thermostats(hass, entry, hub_coordinator) ) ) @@ -159,7 +177,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: WattsVisionConfigEntry ) -> bool: """Unload a config entry.""" - for device_coordinator in entry.runtime_data.device_coordinators.values(): - device_coordinator.unsubscribe_hub_listener() + 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/climate.py b/homeassistant/components/watts/climate.py index 6b6feed80c8a1f..6497909154bca1 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -20,8 +20,8 @@ from . import WattsVisionConfigEntry from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC -from .coordinator import WattsVisionDeviceCoordinator -from .entity import WattsVisionEntity +from .coordinator import WattsVisionThermostatCoordinator +from .entity import WattsVisionThermostatEntity _LOGGER = logging.getLogger(__name__) @@ -35,27 +35,27 @@ async def async_setup_entry( ) -> None: """Set up Watts Vision climate entities from a config entry.""" - device_coordinators = entry.runtime_data.device_coordinators + thermostat_coordinators = entry.runtime_data.thermostat_coordinators known_device_ids: set[str] = set() @callback - def _check_new_devices() -> None: - """Check for new devices.""" - current_device_ids = set(device_coordinators.keys()) + 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 device(s)", + "Adding climate entities for %d new thermostat(s)", len(new_device_ids), ) new_entities = [ WattsVisionClimate( - device_coordinators[device_id], - device_coordinators[device_id].data, + thermostat_coordinators[device_id], + thermostat_coordinators[device_id].data.thermostat, ) for device_id in new_device_ids ] @@ -63,19 +63,19 @@ def _check_new_devices() -> None: known_device_ids.update(new_device_ids) async_add_entities(new_entities) - _check_new_devices() + _check_new_thermostats() - # Listen for new devices + # Listen for new thermostats entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{entry.entry_id}_new_device", - _check_new_devices, + _check_new_thermostats, ) ) -class WattsVisionClimate(WattsVisionEntity, ClimateEntity): +class WattsVisionClimate(WattsVisionThermostatEntity, ClimateEntity): """Representation of a Watts Vision heater as a climate entity.""" _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE @@ -84,18 +84,17 @@ class WattsVisionClimate(WattsVisionEntity, ClimateEntity): def __init__( self, - coordinator: WattsVisionDeviceCoordinator, - device: ThermostatDevice, + coordinator: WattsVisionThermostatCoordinator, + thermostat: ThermostatDevice, ) -> None: """Initialize the climate entity.""" - super().__init__(coordinator, device.device_id) - self._device = device + super().__init__(coordinator, thermostat.device_id) - self._attr_min_temp = device.min_allowed_temperature - self._attr_max_temp = device.max_allowed_temperature + self._attr_min_temp = thermostat.min_allowed_temperature + self._attr_max_temp = thermostat.max_allowed_temperature - if device.temperature_unit.upper() == "C": + if thermostat.temperature_unit.upper() == "C": self._attr_temperature_unit = UnitOfTemperature.CELSIUS else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT @@ -103,17 +102,17 @@ def __init__( @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self.device.current_temperature + return self.thermostat.current_temperature @property def target_temperature(self) -> float | None: """Return the temperature setpoint.""" - return self.device.setpoint + return self.thermostat.setpoint @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(self.thermostat.thermostat_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 9d49811830f6ac..c9f344f5e9eef4 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING @@ -14,7 +15,7 @@ WattsVisionError, WattsVisionTimeoutError, ) -from visionpluspython.models import Device +from visionpluspython.models import Device, ThermostatDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -37,6 +38,13 @@ _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.""" @@ -160,8 +168,10 @@ def device_ids(self) -> list[str]: return list((self.data or {}).keys()) -class WattsVisionDeviceCoordinator(DataUpdateCoordinator[Device]): - """Device coordinator for individual updates.""" +class WattsVisionThermostatCoordinator( + DataUpdateCoordinator[WattsVisionThermostatData] +): + """Thermostat device coordinator for individual updates.""" def __init__( self, @@ -171,7 +181,7 @@ def __init__( hub_coordinator: WattsVisionHubCoordinator, device_id: str, ) -> None: - """Initialize the device coordinator.""" + """Initialize the thermostat coordinator.""" super().__init__( hass, _LOGGER, @@ -193,10 +203,11 @@ 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] - self.async_set_updated_data(device) + assert isinstance(device, ThermostatDevice) + self.async_set_updated_data(WattsVisionThermostatData(thermostat=device)) - async def _async_update_data(self) -> Device: - """Refresh specific 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 @@ -223,8 +234,9 @@ async def _async_update_data(self) -> Device: if not device: raise UpdateFailed(f"Device {self.device_id} not found") - _LOGGER.debug("Refreshed device %s", self.device_id) - return device + 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.""" diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py index c77e8081063423..315196c8e1cfe3 100644 --- a/homeassistant/components/watts/diagnostics.py +++ b/homeassistant/components/watts/diagnostics.py @@ -5,9 +5,12 @@ from dataclasses import asdict, is_dataclass from typing import Any +from visionpluspython.models import Device + from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import WattsVisionConfigEntry @@ -19,7 +22,9 @@ } -def _get_coordinator_diagnostics(coordinator: Any) -> dict[str, Any]: +def _get_coordinator_diagnostics( + coordinator: DataUpdateCoordinator[Any], +) -> dict[str, Any]: """Extract diagnostics from a coordinator.""" return { "last_update_success": coordinator.last_update_success, @@ -34,7 +39,7 @@ def _get_coordinator_diagnostics(coordinator: Any) -> dict[str, Any]: } -def _device_to_dict(device: Any) -> dict[str, Any]: +def _device_to_dict(device: Device) -> dict[str, Any]: """Convert Device object to dict for diagnostics.""" if not (is_dataclass(device) and not isinstance(device, type)): raise TypeError("Expected dataclass instance") @@ -50,13 +55,13 @@ async def async_get_config_entry_diagnostics( devices_diagnostics: dict[str, Any] = {} for device_id, device in hub_coordinator.data.items(): - device_coordinator = runtime_data.device_coordinators.get(device_id) + thermostat_coordinator = runtime_data.thermostat_coordinators.get(device_id) device_data = _device_to_dict(device) - if device_coordinator: + if thermostat_coordinator: device_data["coordinator"] = _get_coordinator_diagnostics( - device_coordinator + thermostat_coordinator ) devices_diagnostics[device_id] = device_data @@ -84,11 +89,13 @@ async def async_get_device_diagnostics( if not device_data: return {"error": "Device not found in coordinator data"} - device_coordinator = runtime_data.device_coordinators.get(device_id) + thermostat_coordinator = runtime_data.thermostat_coordinators.get(device_id) diagnostics = _device_to_dict(device_data) - if device_coordinator: - diagnostics["coordinator"] = _get_coordinator_diagnostics(device_coordinator) + if thermostat_coordinator: + diagnostics["coordinator"] = _get_coordinator_diagnostics( + thermostat_coordinator + ) return diagnostics diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index c099e4159ef0ae..4b429cf4c5518f 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -2,22 +2,22 @@ from __future__ import annotations -from visionpluspython.models import Device +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 WattsVisionDeviceCoordinator +from .coordinator import WattsVisionThermostatCoordinator -class WattsVisionEntity(CoordinatorEntity[WattsVisionDeviceCoordinator]): - """Base entity for Watts Vision integration.""" +class WattsVisionThermostatEntity(CoordinatorEntity[WattsVisionThermostatCoordinator]): + """Base entity for Watts Vision thermostat devices.""" _attr_has_entity_name = True def __init__( - self, coordinator: WattsVisionDeviceCoordinator, device_id: str + self, coordinator: WattsVisionThermostatCoordinator, device_id: str ) -> None: """Initialize the entity.""" @@ -26,18 +26,18 @@ def __init__( self._attr_unique_id = device_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device_id)}, - name=self.device.device_name, + name=self.thermostat.device_name, manufacturer="Watts", - model=f"Vision+ {self.device.device_type}", - suggested_area=self.device.room_name, + model=f"Vision+ {self.thermostat.device_type}", + suggested_area=self.thermostat.room_name, ) @property - def device(self) -> Device: - """Return the device object from the coordinator data.""" - return self.coordinator.data + 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.is_online + return super().available and self.coordinator.data.thermostat.is_online diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 1eeb80e66f35cb..49e15739730909 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["visionpluspython==1.0.1"] + "requirements": ["visionpluspython==1.0.2"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 00faf76fe10f1e..d175bbf6dd8025 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -65,4 +65,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 93cd23c31a7578..e21f8fd44c392f 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 55eaa3dd01699a..e86a3348002a31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3127,7 +3127,7 @@ victron-vrm==0.1.8 vilfo-api-client==0.5.0 # homeassistant.components.watts -visionpluspython==1.0.1 +visionpluspython==1.0.2 # homeassistant.components.caldav vobject==0.9.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcb2dcf7919432..1fd628fcb0d3d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2612,7 +2612,7 @@ victron-vrm==0.1.8 vilfo-api-client==0.5.0 # homeassistant.components.watts -visionpluspython==1.0.1 +visionpluspython==1.0.2 # homeassistant.components.caldav vobject==0.9.9 diff --git a/visionpluspython b/visionpluspython new file mode 160000 index 00000000000000..8f32d5e63149dc --- /dev/null +++ b/visionpluspython @@ -0,0 +1 @@ +Subproject commit 8f32d5e63149dc8c7651dc1dcf5b492589c74be3 From f7b6aa3f8c697f46b045989e94723f5a810abb9a Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 17 Dec 2025 15:02:40 +0000 Subject: [PATCH 35/43] remove unused file --- visionpluspython | 1 - 1 file changed, 1 deletion(-) delete mode 160000 visionpluspython diff --git a/visionpluspython b/visionpluspython deleted file mode 160000 index 8f32d5e63149dc..00000000000000 --- a/visionpluspython +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8f32d5e63149dc8c7651dc1dcf5b492589c74be3 From d91e26132acfa09a53aca2d7e8d68dbea0da439b Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 17 Dec 2025 15:54:37 +0000 Subject: [PATCH 36/43] Add translations and improve docs --- homeassistant/components/watts/climate.py | 6 ++++-- .../components/watts/quality_scale.yaml | 20 ++++++++++++------- homeassistant/components/watts/strings.json | 8 ++++++++ tests/components/watts/test_climate.py | 8 ++++++-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index 6497909154bca1..e9f21b974f57a8 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -126,7 +126,8 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ) except RuntimeError as err: raise HomeAssistantError( - f"Error setting temperature for {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="set_temperature_error", ) from err _LOGGER.debug( @@ -147,7 +148,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.coordinator.client.set_thermostat_mode(self.device_id, mode) except (ValueError, RuntimeError) as err: raise HomeAssistantError( - f"Error setting HVAC mode for {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="set_hvac_mode_error", ) from err _LOGGER.debug( diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index d175bbf6dd8025..f8edd77900a2f1 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -44,11 +44,11 @@ rules: comment: Integration is a cloud service and does not support discovery. docs-data-update: done docs-examples: done - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done @@ -56,10 +56,16 @@ rules: entity-translations: status: exempt comment: No entity required translations. - exception-translations: todo - icon-translations: todo - reconfiguration-flow: todo - repair-issues: todo + exception-translations: done + icon-translations: + status: exempt + comment: Thermostat entities use standard HA Climate entity. + reconfiguration-flow: + status: exempt + comment: This integration uses OAuth2 and has no configurable settings beyond authentication. + repair-issues: + status: exempt + comment: No actionable repair scenarios, auth issues are handled by reauthentication flow. stale-devices: done # Platinum diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index f22819d650d5d6..edcaac1e540e0b 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -27,5 +27,13 @@ "title": "[%key:common::config_flow::title::reauth%]" } } + }, + "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/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 601efea66fdf22..13faafa560c380 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -226,7 +226,9 @@ async def test_set_temperature_api_error( # Make the API call fail mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error") - with pytest.raises(HomeAssistantError, match="Error setting temperature"): + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the temperature" + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -248,7 +250,9 @@ async def test_set_hvac_mode_value_error( mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode") - with pytest.raises(HomeAssistantError, match="Error setting HVAC 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, From 8e119786739b6e76493837ea0bfcdb96de381524 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Wed, 17 Dec 2025 15:56:46 +0000 Subject: [PATCH 37/43] Set quality scale to platinium --- homeassistant/components/watts/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 49e15739730909..0f0d62f3eb8770 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["visionpluspython==1.0.2"] } From 3b43cc24e2f3bd9ea4e06b1a4ecbffa0059c1f4a Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 18 Dec 2025 14:22:35 +0000 Subject: [PATCH 38/43] Remove reauth and diagnotics --- homeassistant/components/watts/config_flow.py | 25 +--- homeassistant/components/watts/coordinator.py | 15 +- homeassistant/components/watts/diagnostics.py | 101 -------------- homeassistant/components/watts/manifest.json | 2 +- .../components/watts/quality_scale.yaml | 8 +- homeassistant/components/watts/strings.json | 8 +- .../watts/snapshots/test_diagnostics.ambr | 123 ---------------- tests/components/watts/test_config_flow.py | 131 ------------------ tests/components/watts/test_diagnostics.py | 57 -------- 9 files changed, 11 insertions(+), 459 deletions(-) delete mode 100644 homeassistant/components/watts/diagnostics.py delete mode 100644 tests/components/watts/snapshots/test_diagnostics.ambr delete mode 100644 tests/components/watts/test_diagnostics.py diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index d0598fe06b35e0..c71e67528aa2a2 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -1,12 +1,11 @@ """Config flow for Watts Vision integration.""" -from collections.abc import Mapping import logging from typing import Any from visionpluspython.auth import WattsVisionAuth -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -33,20 +32,6 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauthentication upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm reauthentication dialog.""" - if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - return await self.async_step_user() - async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -57,14 +42,6 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="invalid_token") await self.async_set_unique_id(user_id) - - if self.source == SOURCE_REAUTH: - self._abort_if_unique_id_mismatch(reason="wrong_account") - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates=data, - ) - self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c9f344f5e9eef4..0caccf8d6f25fc 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -70,9 +70,8 @@ async def async_setup(self) -> None: """Set up the coordinator by discovering devices.""" try: devices_list = await self.client.discover_devices() - except WattsVisionAuthError as err: - raise ConfigEntryAuthFailed("Authentication failed") from err except ( + WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, @@ -98,10 +97,8 @@ async def _async_update_data(self) -> dict[str, Device]: ): try: devices_list = await self.client.discover_devices() - except WattsVisionAuthError as err: - _LOGGER.warning("Periodic discovery failed with auth error: %s", err) - raise ConfigEntryAuthFailed("Authentication failed") from err except ( + WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, @@ -131,9 +128,8 @@ async def _async_update_data(self) -> dict[str, Device]: try: devices = await self.client.get_devices_report(device_ids) - except WattsVisionAuthError as err: - raise ConfigEntryAuthFailed("Authentication failed") from err except ( + WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, @@ -218,9 +214,8 @@ async def _async_update_data(self) -> WattsVisionThermostatData: try: device = await self.client.get_device(self.device_id, refresh=True) - except WattsVisionAuthError as err: - raise ConfigEntryAuthFailed("Authentication failed") from err except ( + WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py deleted file mode 100644 index 315196c8e1cfe3..00000000000000 --- a/homeassistant/components/watts/diagnostics.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Diagnostics support for Watts Vision+.""" - -from __future__ import annotations - -from dataclasses import asdict, is_dataclass -from typing import Any - -from visionpluspython.models import Device - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import WattsVisionConfigEntry - -TO_REDACT = { - "access_token", - "refresh_token", - "id_token", - "profile_info", -} - - -def _get_coordinator_diagnostics( - coordinator: DataUpdateCoordinator[Any], -) -> dict[str, Any]: - """Extract diagnostics from a coordinator.""" - return { - "last_update_success": coordinator.last_update_success, - "update_interval": ( - coordinator.update_interval.total_seconds() - if coordinator.update_interval - else None - ), - "last_exception": ( - str(coordinator.last_exception) if coordinator.last_exception else None - ), - } - - -def _device_to_dict(device: Device) -> dict[str, Any]: - """Convert Device object to dict for diagnostics.""" - if not (is_dataclass(device) and not isinstance(device, type)): - raise TypeError("Expected dataclass instance") - return asdict(device) - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: WattsVisionConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - runtime_data = entry.runtime_data - hub_coordinator = runtime_data.hub_coordinator - - devices_diagnostics: dict[str, Any] = {} - for device_id, device in hub_coordinator.data.items(): - thermostat_coordinator = runtime_data.thermostat_coordinators.get(device_id) - - device_data = _device_to_dict(device) - - if thermostat_coordinator: - device_data["coordinator"] = _get_coordinator_diagnostics( - thermostat_coordinator - ) - - devices_diagnostics[device_id] = device_data - - hub_diagnostics = _get_coordinator_diagnostics(hub_coordinator) - hub_diagnostics["device_count"] = len(hub_coordinator.data) - - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "hub_coordinator": hub_diagnostics, - "devices": devices_diagnostics, - } - - -async def async_get_device_diagnostics( - hass: HomeAssistant, entry: WattsVisionConfigEntry, device: DeviceEntry -) -> dict[str, Any]: - """Return diagnostics for a device.""" - runtime_data = entry.runtime_data - hub_coordinator = runtime_data.hub_coordinator - - device_id = next(iter(device.identifiers))[1] - - device_data = hub_coordinator.data.get(device_id) - if not device_data: - return {"error": "Device not found in coordinator data"} - - thermostat_coordinator = runtime_data.thermostat_coordinators.get(device_id) - - diagnostics = _device_to_dict(device_data) - - if thermostat_coordinator: - diagnostics["coordinator"] = _get_coordinator_diagnostics( - thermostat_coordinator - ) - - return diagnostics diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 0f0d62f3eb8770..40bcf375760bf7 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", - "quality_scale": "platinum", + "quality_scale": "bronze", "requirements": ["visionpluspython==1.0.2"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index f8edd77900a2f1..626e1225eda9a3 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -30,12 +30,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: done + reauthentication-flow: todo test-coverage: done # Gold devices: done - diagnostics: done + diagnostics: todo discovery-update-info: status: exempt comment: Integration does not support discovery. @@ -60,9 +60,7 @@ rules: icon-translations: status: exempt comment: Thermostat entities use standard HA Climate entity. - reconfiguration-flow: - status: exempt - comment: This integration uses OAuth2 and has no configurable settings beyond authentication. + reconfiguration-flow: todo repair-issues: status: exempt comment: No actionable repair scenarios, auth issues are handled by reauthentication flow. diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index edcaac1e540e0b..967a1167f8f6a1 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -11,9 +11,7 @@ "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%]", - "reauth_successful": "Successfully reauthenticated.", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "wrong_account": "The user credentials provided do not match this Watts Vision account." + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -21,10 +19,6 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - }, - "reauth_confirm": { - "description": "The Watts Vision integration needs to re-authenticate your account.", - "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/watts/snapshots/test_diagnostics.ambr b/tests/components/watts/snapshots/test_diagnostics.ambr deleted file mode 100644 index dfa14bb4d2b358..00000000000000 --- a/tests/components/watts/snapshots/test_diagnostics.ambr +++ /dev/null @@ -1,123 +0,0 @@ -# serializer version: 1 -# name: test_config_entry_diagnostics - dict({ - 'devices': dict({ - 'thermostat_123': dict({ - 'available_thermostat_modes': list([ - 'Program', - 'Eco', - 'Comfort', - 'Off', - 'Defrost', - 'Timer', - ]), - 'coordinator': dict({ - 'last_exception': None, - 'last_update_success': True, - 'update_interval': None, - }), - 'current_temperature': 20.8, - 'device_id': 'thermostat_123', - 'device_name': 'Living Room Thermostat', - 'device_type': 'thermostat', - 'interface': 'homeassistant.components.THERMOSTAT', - 'is_online': True, - 'max_allowed_temperature': 30.0, - 'min_allowed_temperature': 5.0, - 'room_name': 'Living Room', - 'setpoint': 22.0, - 'temperature_unit': 'C', - 'thermostat_mode': 'Comfort', - }), - 'thermostat_456': dict({ - 'available_thermostat_modes': list([ - 'Program', - 'Eco', - 'Comfort', - 'Off', - ]), - 'coordinator': dict({ - 'last_exception': None, - 'last_update_success': True, - 'update_interval': None, - }), - 'current_temperature': 19.2, - 'device_id': 'thermostat_456', - 'device_name': 'Bedroom Thermostat', - 'device_type': 'thermostat', - 'interface': 'homeassistant.components.THERMOSTAT', - 'is_online': True, - 'max_allowed_temperature': 30.0, - 'min_allowed_temperature': 5.0, - 'room_name': 'Bedroom', - 'setpoint': 21.0, - 'temperature_unit': 'C', - 'thermostat_mode': 'Program', - }), - }), - 'entry': dict({ - 'data': dict({ - 'auth_implementation': 'watts', - 'device_id': 'test-device-id', - 'token': dict({ - 'access_token': '**REDACTED**', - 'expires_at': 9999999999, - 'id_token': '**REDACTED**', - 'profile_info': '**REDACTED**', - 'refresh_token': '**REDACTED**', - }), - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'watts', - 'entry_id': '01J0BC4QM2YBRP6H5G933CETI8', - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'subentries': list([ - ]), - 'title': 'Watts Vision', - 'unique_id': 'test-device-id', - 'version': 1, - }), - 'hub_coordinator': dict({ - 'device_count': 2, - 'last_exception': None, - 'last_update_success': True, - 'update_interval': 30.0, - }), - }) -# --- -# name: test_device_diagnostics - dict({ - 'available_thermostat_modes': list([ - 'Program', - 'Eco', - 'Comfort', - 'Off', - 'Defrost', - 'Timer', - ]), - 'coordinator': dict({ - 'last_exception': None, - 'last_update_success': True, - 'update_interval': None, - }), - 'current_temperature': 20.8, - 'device_id': 'thermostat_123', - 'device_name': 'Living Room Thermostat', - 'device_type': 'thermostat', - 'interface': 'homeassistant.components.THERMOSTAT', - 'is_online': True, - 'max_allowed_temperature': 30.0, - 'min_allowed_temperature': 5.0, - 'room_name': 'Living Room', - 'setpoint': 22.0, - 'temperature_unit': 'C', - 'thermostat_mode': 'Comfort', - }) -# --- diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 265c0ddc669777..862fb3513f507b 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -246,134 +246,3 @@ async def test_unique_config_entry( assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") -async def test_reauth_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test reauthentication flow.""" - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="user123", - data={ - "auth_implementation": DOMAIN, - "token": { - "refresh_token": "old-refresh-token", - "access_token": "old-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "expires_at": 0, - }, - }, - ) - mock_config_entry.add_to_hass(hass) - - mock_config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] - assert result.get("step_id") == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - 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": "new-refresh-token", - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - with patch( - "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", - return_value="user123", - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "reauth_successful" - assert mock_config_entry.data["token"]["refresh_token"] == "new-refresh-token" - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_reauth_wrong_account( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test reauthentication with wrong account.""" - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="user123", - data={ - "auth_implementation": DOMAIN, - "token": { - "refresh_token": "old-refresh-token", - "access_token": "old-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "expires_at": 0, - }, - }, - ) - mock_config_entry.add_to_hass(hass) - - mock_config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] - assert result.get("step_id") == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - 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": "new-refresh-token", - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - with patch( - "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", - return_value="different_user", - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "wrong_account" diff --git a/tests/components/watts/test_diagnostics.py b/tests/components/watts/test_diagnostics.py deleted file mode 100644 index 27161e5a5aadd7..00000000000000 --- a/tests/components/watts/test_diagnostics.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for Watts Vision diagnostics.""" - -from unittest.mock import AsyncMock - -from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props - -from homeassistant.components.watts.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from . import setup_integration - -from tests.common import MockConfigEntry -from tests.components.diagnostics import ( - get_diagnostics_for_config_entry, - get_diagnostics_for_device, -) -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_watts_client: AsyncMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test config entry diagnostics.""" - await setup_integration(hass, mock_config_entry) - - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) - - assert result == snapshot(exclude=props("created_at", "modified_at")) - - -async def test_device_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - device_registry: dr.DeviceRegistry, - mock_watts_client: AsyncMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test device diagnostics.""" - await setup_integration(hass, mock_config_entry) - - device = device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_123")}) - assert device is not None - - result = await get_diagnostics_for_device( - hass, hass_client, mock_config_entry, device - ) - - assert result == snapshot From c8d512f5107e07ebd385091044f6091509af096d Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 18 Dec 2025 14:28:21 +0000 Subject: [PATCH 39/43] Fix typo and auth error --- homeassistant/components/watts/coordinator.py | 5 +++-- tests/components/watts/test_climate.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 0caccf8d6f25fc..8fc4416f0f5a44 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -70,8 +70,9 @@ async def async_setup(self) -> None: """Set up the coordinator by discovering devices.""" try: devices_list = await self.client.discover_devices() + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err except ( - WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index 13faafa560c380..aa8b40aec0f6d4 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -122,7 +122,7 @@ async def test_fast_polling_stops_after_duration( # Reset mock to count only fast polling calls mock_watts_client.get_device.reset_mock() - # Should be in fast pooling 55s after + # Should be in fast polling 55s after mock_watts_client.get_device.reset_mock() freezer.tick(timedelta(seconds=55)) async_fire_time_changed(hass) From 4da182d0e619578048299e8ad55a605b94834270 Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 18 Dec 2025 14:56:31 +0000 Subject: [PATCH 40/43] Merge discovery logic into async update data Remove unecessary isinstance Replace TEST_DEVICE_ID by TEST_USER_ID --- homeassistant/components/watts/__init__.py | 4 -- homeassistant/components/watts/coordinator.py | 41 ++++-------- .../components/watts/quality_scale.yaml | 2 +- tests/components/watts/conftest.py | 14 ++--- .../watts/snapshots/test_climate.ambr | 4 +- tests/components/watts/test_config_flow.py | 63 +++++++++---------- 6 files changed, 52 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 6fdf85d3a44a45..b30c5aea4870d9 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -134,12 +134,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) client = WattsVisionClient(auth, session) hub_coordinator = WattsVisionHubCoordinator(hass, client, entry) - await hub_coordinator.async_setup() await hub_coordinator.async_config_entry_first_refresh() - # Stale device tracking - hub_coordinator.previous_devices = set(hub_coordinator.data.keys()) - thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] = {} for device_id in hub_coordinator.device_ids: device = hub_coordinator.data[device_id] diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 8fc4416f0f5a44..5dbb5571c6372e 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -66,40 +66,22 @@ def __init__( self._last_discovery: datetime | None = None self.previous_devices: set[str] = set() - async def async_setup(self) -> None: - """Set up the coordinator by discovering devices.""" - 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: - raise ConfigEntryNotReady("Failed to discover devices") from err - - devices = {device.device_id: device for device in devices_list} - _LOGGER.info("Initial discovery completed with %d devices", len(devices)) - self.async_set_updated_data(devices) - - self._last_discovery = datetime.now() - 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 self._last_discovery is None or 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 ( - WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, @@ -108,6 +90,8 @@ async def _async_update_data(self) -> dict[str, Device]: 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 ) @@ -129,8 +113,9 @@ async def _async_update_data(self) -> dict[str, Device]: try: devices = await self.client.get_devices_report(device_ids) + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err except ( - WattsVisionAuthError, WattsVisionConnectionError, WattsVisionTimeoutError, WattsVisionDeviceError, diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 626e1225eda9a3..d24ac7d1ce2a77 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -56,7 +56,7 @@ rules: entity-translations: status: exempt comment: No entity required translations. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: Thermostat entities use standard HA Climate entity. diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index ac3d1dd650c87b..6e78bc397b20e5 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -22,7 +22,7 @@ CLIENT_ID = "test_client_id" CLIENT_SECRET = "test_client_secret" -TEST_DEVICE_ID = "test-device-id" +TEST_USER_ID = "test-user-id" TEST_ACCESS_TOKEN = "test-access-token" TEST_REFRESH_TOKEN = "test-refresh-token" TEST_ID_TOKEN = "test-id-token" @@ -66,17 +66,14 @@ def mock_watts_client() -> Generator[AsyncMock]: device_detail_data = load_json_object_fixture("device_detail.json", DOMAIN) discovered_devices = [ - create_device_from_data(device_data) + create_device_from_data(device_data) # type: ignore[arg-type] for device_data in discover_data - if isinstance(device_data, dict) ] device_report = { - device_id: create_device_from_data(device_data) + device_id: create_device_from_data(device_data) # type: ignore[arg-type] for device_id, device_data in device_report_data.items() - if isinstance(device_data, dict) } - assert isinstance(device_detail_data, dict) - device_detail = create_device_from_data(device_detail_data) + 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 @@ -92,7 +89,6 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Watts Vision", data={ - "device_id": TEST_DEVICE_ID, "auth_implementation": DOMAIN, "token": { "access_token": TEST_ACCESS_TOKEN, @@ -103,5 +99,5 @@ def mock_config_entry() -> MockConfigEntry: }, }, entry_id="01J0BC4QM2YBRP6H5G933CETI8", - unique_id=TEST_DEVICE_ID, + unique_id=TEST_USER_ID, ) diff --git a/tests/components/watts/snapshots/test_climate.ambr b/tests/components/watts/snapshots/test_climate.ambr index 566b947b82dbee..88417d17cbbfbd 100644 --- a/tests/components/watts/snapshots/test_climate.ambr +++ b/tests/components/watts/snapshots/test_climate.ambr @@ -45,7 +45,7 @@ # name: test_entities[climate.bedroom_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 19.2, + 'current_temperature': 19.0, 'friendly_name': 'Bedroom Thermostat', 'hvac_modes': list([ , @@ -111,7 +111,7 @@ # name: test_entities[climate.living_room_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 20.8, + 'current_temperature': 20.5, 'friendly_name': 'Living Room Thermostat', 'hvac_modes': list([ , diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 862fb3513f507b..8b56bda1ae1e3a 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -54,7 +54,7 @@ async def test_full_flow( ) with patch( - "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", + "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"]) @@ -99,7 +99,7 @@ async def test_invalid_token_flow( ) with patch( - "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", + "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"]) @@ -210,39 +210,38 @@ async def test_unique_config_entry( ) 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( - "visionpluspython.auth.WattsVisionAuth.extract_user_id_from_token", + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", return_value="user123", ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - if result.get("type") is FlowResultType.EXTERNAL_STEP: - 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, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + 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 result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 From b2cf64793fc678216db61133c014cc2d7f19aa9d Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 18 Dec 2025 15:11:27 +0000 Subject: [PATCH 41/43] Remove unused function to remove device --- homeassistant/components/watts/__init__.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index b30c5aea4870d9..8cbc90548b1960 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -15,11 +15,7 @@ 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, - device_registry as dr, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN @@ -85,21 +81,6 @@ def _handle_new_thermostats( async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device") -async def async_remove_config_entry_device( - hass: HomeAssistant, - config_entry: WattsVisionConfigEntry, - device_entry: dr.DeviceEntry, -) -> bool: - """Remove a config entry from a device.""" - # Allow removal if device is not in coordinator data - return not any( - identifier - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] in config_entry.runtime_data.hub_coordinator.data - ) - - async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: """Set up Watts Vision from a config entry.""" From e852d90b46989135e10eb69784e2a5be31443003 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:32:50 +0100 Subject: [PATCH 42/43] Update homeassistant/components/watts/quality_scale.yaml Co-authored-by: Joost Lekkerkerker --- homeassistant/components/watts/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index d24ac7d1ce2a77..152dcbbd3f5c53 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -41,7 +41,7 @@ rules: comment: Integration does not support discovery. discovery: status: exempt - comment: Integration is a cloud service and does not support discovery. + comment: Device doesn't have discoverable properties docs-data-update: done docs-examples: done docs-known-limitations: done From ea4fa67e2476cfaac09a348596a98037de674f1c Mon Sep 17 00:00:00 2001 From: theobld-ww Date: Thu, 18 Dec 2025 16:02:13 +0000 Subject: [PATCH 43/43] Add / removal discovery tests --- tests/components/watts/test_init.py | 99 ++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index 67a5ff4f055aba..98a85690972bd9 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -1,8 +1,10 @@ """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, @@ -11,14 +13,20 @@ WattsVisionError, WattsVisionTimeoutError, ) +from visionpluspython.models import create_device_from_data -from homeassistant.components.watts.const import OAUTH2_TOKEN +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 +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -178,3 +186,90 @@ async def test_setup_entry_discover_devices_errors( 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