1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

Add Watts Vision + integration with tests (#153022)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
theobld-ww
2025-12-18 17:41:23 +01:00
committed by GitHub
parent cb275f65ba
commit c2440c4ebd
28 changed files with 1989 additions and 0 deletions

View File

@@ -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.*

2
CODEOWNERS generated
View File

@@ -1798,6 +1798,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger

View File

@@ -0,0 +1,160 @@
"""The Watts Vision + integration."""
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
import logging
from aiohttp import ClientError, ClientResponseError
from visionpluspython.auth import WattsVisionAuth
from visionpluspython.client import WattsVisionClient
from visionpluspython.models import ThermostatDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN
from .coordinator import (
WattsVisionHubCoordinator,
WattsVisionThermostatCoordinator,
WattsVisionThermostatData,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE]
@dataclass
class WattsVisionRuntimeData:
"""Runtime data for Watts Vision integration."""
auth: WattsVisionAuth
hub_coordinator: WattsVisionHubCoordinator
thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator]
client: WattsVisionClient
type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData]
@callback
def _handle_new_thermostats(
hass: HomeAssistant,
entry: WattsVisionConfigEntry,
hub_coordinator: WattsVisionHubCoordinator,
) -> None:
"""Check for new thermostat devices and create coordinators."""
current_device_ids = set(hub_coordinator.data.keys())
known_device_ids = set(entry.runtime_data.thermostat_coordinators.keys())
new_device_ids = current_device_ids - known_device_ids
if not new_device_ids:
return
_LOGGER.info("Discovered %d new device(s): %s", len(new_device_ids), new_device_ids)
thermostat_coordinators = entry.runtime_data.thermostat_coordinators
client = entry.runtime_data.client
for device_id in new_device_ids:
device = hub_coordinator.data[device_id]
if not isinstance(device, ThermostatDevice):
continue
thermostat_coordinator = WattsVisionThermostatCoordinator(
hass, client, entry, hub_coordinator, device_id
)
thermostat_coordinator.async_set_updated_data(
WattsVisionThermostatData(thermostat=device)
)
thermostat_coordinators[device_id] = thermostat_coordinator
_LOGGER.debug("Created thermostat coordinator for device %s", device_id)
async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device")
async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool:
"""Set up Watts Vision from a config entry."""
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
"OAuth2 implementation temporarily unavailable"
) from err
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as err:
if HTTPStatus.BAD_REQUEST <= err.status < HTTPStatus.INTERNAL_SERVER_ERROR:
raise ConfigEntryAuthFailed("OAuth session not valid") from err
raise ConfigEntryNotReady("Temporary connection error") from err
except ClientError as err:
raise ConfigEntryNotReady("Network issue during OAuth setup") from err
session = aiohttp_client.async_get_clientsession(hass)
auth = WattsVisionAuth(
oauth_session=oauth_session,
session=session,
)
client = WattsVisionClient(auth, session)
hub_coordinator = WattsVisionHubCoordinator(hass, client, entry)
await hub_coordinator.async_config_entry_first_refresh()
thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] = {}
for device_id in hub_coordinator.device_ids:
device = hub_coordinator.data[device_id]
if not isinstance(device, ThermostatDevice):
continue
thermostat_coordinator = WattsVisionThermostatCoordinator(
hass, client, entry, hub_coordinator, device_id
)
thermostat_coordinator.async_set_updated_data(
WattsVisionThermostatData(thermostat=device)
)
thermostat_coordinators[device_id] = thermostat_coordinator
entry.runtime_data = WattsVisionRuntimeData(
auth=auth,
hub_coordinator=hub_coordinator,
thermostat_coordinators=thermostat_coordinators,
client=client,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Listener for dynamic device detection
entry.async_on_unload(
hub_coordinator.async_add_listener(
lambda: _handle_new_thermostats(hass, entry, hub_coordinator)
)
)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: WattsVisionConfigEntry
) -> bool:
"""Unload a config entry."""
for thermostat_coordinator in entry.runtime_data.thermostat_coordinators.values():
thermostat_coordinator.unsubscribe_hub_listener()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -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)

View File

@@ -0,0 +1,164 @@
"""Climate platform for Watts Vision integration."""
from __future__ import annotations
import logging
from typing import Any
from visionpluspython.models import ThermostatDevice
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WattsVisionConfigEntry
from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC
from .coordinator import WattsVisionThermostatCoordinator
from .entity import WattsVisionThermostatEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: WattsVisionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Watts Vision climate entities from a config entry."""
thermostat_coordinators = entry.runtime_data.thermostat_coordinators
known_device_ids: set[str] = set()
@callback
def _check_new_thermostats() -> None:
"""Check for new thermostat devices."""
current_device_ids = set(thermostat_coordinators.keys())
new_device_ids = current_device_ids - known_device_ids
if not new_device_ids:
return
_LOGGER.debug(
"Adding climate entities for %d new thermostat(s)",
len(new_device_ids),
)
new_entities = [
WattsVisionClimate(
thermostat_coordinators[device_id],
thermostat_coordinators[device_id].data.thermostat,
)
for device_id in new_device_ids
]
known_device_ids.update(new_device_ids)
async_add_entities(new_entities)
_check_new_thermostats()
# Listen for new thermostats
entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{entry.entry_id}_new_device",
_check_new_thermostats,
)
)
class WattsVisionClimate(WattsVisionThermostatEntity, ClimateEntity):
"""Representation of a Watts Vision heater as a climate entity."""
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]
_attr_name = None
def __init__(
self,
coordinator: WattsVisionThermostatCoordinator,
thermostat: ThermostatDevice,
) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator, thermostat.device_id)
self._attr_min_temp = thermostat.min_allowed_temperature
self._attr_max_temp = thermostat.max_allowed_temperature
if thermostat.temperature_unit.upper() == "C":
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
else:
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.thermostat.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the temperature setpoint."""
return self.thermostat.setpoint
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac mode."""
return THERMOSTAT_MODE_TO_HVAC.get(self.thermostat.thermostat_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
try:
await self.coordinator.client.set_thermostat_temperature(
self.device_id, temperature
)
except RuntimeError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_error",
) from err
_LOGGER.debug(
"Successfully set temperature to %s for %s",
temperature,
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode]
try:
await self.coordinator.client.set_thermostat_mode(self.device_id, mode)
except (ValueError, RuntimeError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_hvac_mode_error",
) from err
_LOGGER.debug(
"Successfully set HVAC mode to %s (ThermostatMode.%s) for %s",
hvac_mode,
mode.name,
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()

View File

@@ -0,0 +1,50 @@
"""Config flow for Watts Vision integration."""
import logging
from typing import Any
from visionpluspython.auth import WattsVisionAuth
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, OAUTH2_SCOPES
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Watts Vision OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra parameters for OAuth2 authentication."""
return {
"scope": " ".join(OAUTH2_SCOPES),
"access_type": "offline",
"prompt": "consent",
}
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the OAuth2 flow."""
access_token = data["token"]["access_token"]
user_id = WattsVisionAuth.extract_user_id_from_token(access_token)
if not user_id:
return self.async_abort(reason="invalid_token")
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Watts Vision +",
data=data,
)

View File

@@ -0,0 +1,37 @@
"""Constants for the Watts Vision+ integration."""
from visionpluspython.models import ThermostatMode
from homeassistant.components.climate import HVACMode
DOMAIN = "watts"
OAUTH2_AUTHORIZE = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/authorize"
OAUTH2_TOKEN = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/token"
OAUTH2_SCOPES = [
"openid",
"offline_access",
"https://visionlogin.onmicrosoft.com/homeassistant-api/homeassistant.read",
]
# Update intervals
UPDATE_INTERVAL_SECONDS = 30
FAST_POLLING_INTERVAL_SECONDS = 5
DISCOVERY_INTERVAL_MINUTES = 15
# Mapping from Watts Vision + modes to Home Assistant HVAC modes
THERMOSTAT_MODE_TO_HVAC = {
"Program": HVACMode.AUTO,
"Eco": HVACMode.HEAT,
"Comfort": HVACMode.HEAT,
"Off": HVACMode.OFF,
}
# Mapping from Home Assistant HVAC modes to Watts Vision + modes
HVAC_MODE_TO_THERMOSTAT = {
HVACMode.HEAT: ThermostatMode.COMFORT,
HVACMode.OFF: ThermostatMode.OFF,
HVACMode.AUTO: ThermostatMode.PROGRAM,
}

View File

@@ -0,0 +1,228 @@
"""Data coordinator for Watts Vision integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING
from visionpluspython.client import WattsVisionClient
from visionpluspython.exceptions import (
WattsVisionAuthError,
WattsVisionConnectionError,
WattsVisionDeviceError,
WattsVisionError,
WattsVisionTimeoutError,
)
from visionpluspython.models import Device, ThermostatDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DISCOVERY_INTERVAL_MINUTES,
DOMAIN,
FAST_POLLING_INTERVAL_SECONDS,
UPDATE_INTERVAL_SECONDS,
)
if TYPE_CHECKING:
from . import WattsVisionRuntimeData
type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData]
_LOGGER = logging.getLogger(__name__)
@dataclass
class WattsVisionThermostatData:
"""Data class for thermostat device coordinator."""
thermostat: ThermostatDevice
class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Hub coordinator for bulk device discovery and updates."""
def __init__(
self,
hass: HomeAssistant,
client: WattsVisionClient,
config_entry: WattsVisionConfigEntry,
) -> None:
"""Initialize the hub coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS),
config_entry=config_entry,
)
self.client = client
self._last_discovery: datetime | None = None
self.previous_devices: set[str] = set()
async def _async_update_data(self) -> dict[str, Device]:
"""Fetch data and periodic device discovery."""
now = datetime.now()
is_first_refresh = self._last_discovery is None
discovery_interval_elapsed = (
self._last_discovery is not None
and now - self._last_discovery
>= timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)
)
if is_first_refresh or discovery_interval_elapsed:
try:
devices_list = await self.client.discover_devices()
except WattsVisionAuthError as err:
raise ConfigEntryAuthFailed("Authentication failed") from err
except (
WattsVisionConnectionError,
WattsVisionTimeoutError,
WattsVisionDeviceError,
WattsVisionError,
ConnectionError,
TimeoutError,
ValueError,
) as err:
if is_first_refresh:
raise ConfigEntryNotReady("Failed to discover devices") from err
_LOGGER.warning(
"Periodic discovery failed: %s, falling back to update", err
)
else:
self._last_discovery = now
devices = {device.device_id: device for device in devices_list}
current_devices = set(devices.keys())
if stale_devices := self.previous_devices - current_devices:
await self._remove_stale_devices(stale_devices)
self.previous_devices = current_devices
return devices
# Regular update of existing devices
device_ids = list(self.data.keys())
if not device_ids:
return {}
try:
devices = await self.client.get_devices_report(device_ids)
except WattsVisionAuthError as err:
raise ConfigEntryAuthFailed("Authentication failed") from err
except (
WattsVisionConnectionError,
WattsVisionTimeoutError,
WattsVisionDeviceError,
WattsVisionError,
ConnectionError,
TimeoutError,
ValueError,
) as err:
raise UpdateFailed("Failed to update devices") from err
_LOGGER.debug("Updated %d devices", len(devices))
return devices
async def _remove_stale_devices(self, stale_device_ids: set[str]) -> None:
"""Remove stale devices."""
assert self.config_entry is not None
device_registry = dr.async_get(self.hass)
for device_id in stale_device_ids:
_LOGGER.info("Removing stale device: %s", device_id)
device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
@property
def device_ids(self) -> list[str]:
"""Get list of all device IDs."""
return list((self.data or {}).keys())
class WattsVisionThermostatCoordinator(
DataUpdateCoordinator[WattsVisionThermostatData]
):
"""Thermostat device coordinator for individual updates."""
def __init__(
self,
hass: HomeAssistant,
client: WattsVisionClient,
config_entry: WattsVisionConfigEntry,
hub_coordinator: WattsVisionHubCoordinator,
device_id: str,
) -> None:
"""Initialize the thermostat coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_{device_id}",
update_interval=None, # Manual refresh only
config_entry=config_entry,
)
self.client = client
self.device_id = device_id
self.hub_coordinator = hub_coordinator
self._fast_polling_until: datetime | None = None
# Listen to hub coordinator updates
self.unsubscribe_hub_listener = hub_coordinator.async_add_listener(
self._handle_hub_update
)
def _handle_hub_update(self) -> None:
"""Handle updates from hub coordinator."""
if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data:
device = self.hub_coordinator.data[self.device_id]
assert isinstance(device, ThermostatDevice)
self.async_set_updated_data(WattsVisionThermostatData(thermostat=device))
async def _async_update_data(self) -> WattsVisionThermostatData:
"""Refresh specific thermostat device."""
if self._fast_polling_until and datetime.now() > self._fast_polling_until:
self._fast_polling_until = None
self.update_interval = None
_LOGGER.debug(
"Device %s: Fast polling period ended, returning to manual refresh",
self.device_id,
)
try:
device = await self.client.get_device(self.device_id, refresh=True)
except (
WattsVisionAuthError,
WattsVisionConnectionError,
WattsVisionTimeoutError,
WattsVisionDeviceError,
WattsVisionError,
ConnectionError,
TimeoutError,
ValueError,
) as err:
raise UpdateFailed(f"Failed to refresh device {self.device_id}") from err
if not device:
raise UpdateFailed(f"Device {self.device_id} not found")
assert isinstance(device, ThermostatDevice)
_LOGGER.debug("Refreshed thermostat %s", self.device_id)
return WattsVisionThermostatData(thermostat=device)
def trigger_fast_polling(self, duration: int = 60) -> None:
"""Activate fast polling for a specified duration after a command."""
self._fast_polling_until = datetime.now() + timedelta(seconds=duration)
self.update_interval = timedelta(seconds=FAST_POLLING_INTERVAL_SECONDS)
_LOGGER.debug(
"Device %s: Activated fast polling for %d seconds", self.device_id, duration
)

View File

@@ -0,0 +1,43 @@
"""Base entity for Watts Vision integration."""
from __future__ import annotations
from visionpluspython.models import ThermostatDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import WattsVisionThermostatCoordinator
class WattsVisionThermostatEntity(CoordinatorEntity[WattsVisionThermostatCoordinator]):
"""Base entity for Watts Vision thermostat devices."""
_attr_has_entity_name = True
def __init__(
self, coordinator: WattsVisionThermostatCoordinator, device_id: str
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, context=device_id)
self.device_id = device_id
self._attr_unique_id = device_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.device_id)},
name=self.thermostat.device_name,
manufacturer="Watts",
model=f"Vision+ {self.thermostat.device_type}",
suggested_area=self.thermostat.room_name,
)
@property
def thermostat(self) -> ThermostatDevice:
"""Return the thermostat device from the coordinator data."""
return self.coordinator.data.thermostat
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.data.thermostat.is_online

View File

@@ -0,0 +1,11 @@
{
"domain": "watts",
"name": "Watts Vision +",
"codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/watts",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["visionpluspython==1.0.2"]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have configuration parameters.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration does not support discovery.
discovery:
status: exempt
comment: Device doesn't have discoverable properties
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations:
status: exempt
comment: No entity required translations.
exception-translations: todo
icon-translations:
status: exempt
comment: Thermostat entities use standard HA Climate entity.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No actionable repair scenarios, auth issues are handled by reauthentication flow.
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,33 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"invalid_token": "The provided access token is invalid.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"set_hvac_mode_error": {
"message": "An error occurred while setting the HVAC mode."
},
"set_temperature_error": {
"message": "An error occurred while setting the temperature."
}
}
}

View File

@@ -40,6 +40,7 @@ APPLICATION_CREDENTIALS = [
"tesla_fleet",
"twitch",
"volvo",
"watts",
"weheat",
"withings",
"xbox",

View File

@@ -751,6 +751,7 @@ FLOWS = {
"wallbox",
"waqi",
"watergate",
"watts",
"watttime",
"waze_travel_time",
"weatherflow",

View File

@@ -7472,6 +7472,12 @@
"config_flow": false,
"iot_class": "cloud_push"
},
"watts": {
"name": "Watts Vision +",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"watttime": {
"name": "WattTime",
"integration_type": "service",

10
mypy.ini generated
View File

@@ -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

3
requirements_all.txt generated
View File

@@ -3123,6 +3123,9 @@ victron-vrm==0.1.8
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
# homeassistant.components.watts
visionpluspython==1.0.2
# homeassistant.components.caldav
vobject==0.9.9

View File

@@ -2608,6 +2608,9 @@ victron-vrm==0.1.8
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
# homeassistant.components.watts
visionpluspython==1.0.2
# homeassistant.components.caldav
vobject==0.9.9

View File

@@ -0,0 +1,15 @@
"""Tests for the Watts Vision integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Set up the Watts Vision integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,103 @@
"""Fixtures for the Watts integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from visionpluspython.models import create_device_from_data
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.watts.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import (
MockConfigEntry,
load_json_array_fixture,
load_json_object_fixture,
)
CLIENT_ID = "test_client_id"
CLIENT_SECRET = "test_client_secret"
TEST_USER_ID = "test-user-id"
TEST_ACCESS_TOKEN = "test-access-token"
TEST_REFRESH_TOKEN = "test-refresh-token"
TEST_ID_TOKEN = "test-id-token"
TEST_PROFILE_INFO = "test-profile-info"
TEST_EXPIRES_AT = 9999999999
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Ensure the application credentials are registered for each test."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET, name="Watts"),
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.watts.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_watts_client() -> Generator[AsyncMock]:
"""Mock a Watts Vision client."""
with patch(
"homeassistant.components.watts.WattsVisionClient",
autospec=True,
) as mock_client_class:
client = mock_client_class.return_value
discover_data = load_json_array_fixture("discover_devices.json", DOMAIN)
device_report_data = load_json_object_fixture("device_report.json", DOMAIN)
device_detail_data = load_json_object_fixture("device_detail.json", DOMAIN)
discovered_devices = [
create_device_from_data(device_data) # type: ignore[arg-type]
for device_data in discover_data
]
device_report = {
device_id: create_device_from_data(device_data) # type: ignore[arg-type]
for device_id, device_data in device_report_data.items()
}
device_detail = create_device_from_data(device_detail_data) # type: ignore[arg-type]
client.discover_devices.return_value = discovered_devices
client.get_devices_report.return_value = device_report
client.get_device.return_value = device_detail
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Watts Vision",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": TEST_ACCESS_TOKEN,
"refresh_token": TEST_REFRESH_TOKEN,
"id_token": TEST_ID_TOKEN,
"profile_info": TEST_PROFILE_INFO,
"expires_at": TEST_EXPIRES_AT,
},
},
entry_id="01J0BC4QM2YBRP6H5G933CETI8",
unique_id=TEST_USER_ID,
)

View File

@@ -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"
]
}

View File

@@ -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"]
}
}

View File

@@ -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"]
}
]

View File

@@ -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([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.bedroom_thermostat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ClimateEntityFeature: 1>,
'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([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 21.0,
}),
'context': <ANY>,
'entity_id': 'climate.bedroom_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_entities[climate.living_room_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room_thermostat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ClimateEntityFeature: 1>,
'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([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 22.0,
}),
'context': <ANY>,
'entity_id': 'climate.living_room_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---

View File

@@ -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

View File

@@ -0,0 +1,264 @@
"""Tests for the Watts Vision climate platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from visionpluspython.models import ThermostatMode
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the climate entities."""
with patch("homeassistant.components.watts.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_set_temperature(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting temperature."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("climate.living_room_thermostat")
assert state is not None
assert state.attributes.get(ATTR_TEMPERATURE) == 22.0
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_TEMPERATURE: 23.5,
},
blocking=True,
)
mock_watts_client.set_thermostat_temperature.assert_called_once_with(
"thermostat_123", 23.5
)
async def test_set_temperature_triggers_fast_polling(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that setting temperature triggers fast polling."""
await setup_integration(hass, mock_config_entry)
# Trigger fast polling
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_TEMPERATURE: 23.5,
},
blocking=True,
)
# Reset mock to count only fast polling calls
mock_watts_client.get_device.reset_mock()
# Advance time by 5 seconds (fast polling interval)
freezer.tick(timedelta(seconds=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_watts_client.get_device.called
mock_watts_client.get_device.assert_called_with("thermostat_123", refresh=True)
async def test_fast_polling_stops_after_duration(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that fast polling stops after the duration expires."""
await setup_integration(hass, mock_config_entry)
# Trigger fast polling
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_TEMPERATURE: 23.5,
},
blocking=True,
)
# Reset mock to count only fast polling calls
mock_watts_client.get_device.reset_mock()
# Should be in fast polling 55s after
mock_watts_client.get_device.reset_mock()
freezer.tick(timedelta(seconds=55))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_watts_client.get_device.called
mock_watts_client.get_device.reset_mock()
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Should be called one last time to check if duration expired, then stop
# Fast polling should be done now
mock_watts_client.get_device.reset_mock()
freezer.tick(timedelta(seconds=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert not mock_watts_client.get_device.called
async def test_set_hvac_mode_heat(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting HVAC mode to heat."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_HVAC_MODE: HVACMode.HEAT,
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_123", ThermostatMode.COMFORT
)
async def test_set_hvac_mode_auto(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting HVAC mode to auto."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: "climate.bedroom_thermostat",
ATTR_HVAC_MODE: HVACMode.AUTO,
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_456", ThermostatMode.PROGRAM
)
async def test_set_hvac_mode_off(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting HVAC mode to off."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_HVAC_MODE: HVACMode.OFF,
},
blocking=True,
)
mock_watts_client.set_thermostat_mode.assert_called_once_with(
"thermostat_123", ThermostatMode.OFF
)
async def test_set_temperature_api_error(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error handling when setting temperature fails."""
await setup_integration(hass, mock_config_entry)
# Make the API call fail
mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error")
with pytest.raises(
HomeAssistantError, match="An error occurred while setting the temperature"
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_TEMPERATURE: 23.5,
},
blocking=True,
)
async def test_set_hvac_mode_value_error(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error handling when setting mode fails."""
await setup_integration(hass, mock_config_entry)
mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode")
with pytest.raises(
HomeAssistantError, match="An error occurred while setting the HVAC mode"
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: "climate.living_room_thermostat",
ATTR_HVAC_MODE: HVACMode.HEAT,
},
blocking=True,
)

View File

@@ -0,0 +1,247 @@
"""Test the Watts Vision config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.watts.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test the full OAuth2 config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") is FlowResultType.EXTERNAL_STEP
assert "url" in result
assert OAUTH2_AUTHORIZE in result.get("url", "")
assert "response_type=code" in result.get("url", "")
assert "scope=" in result.get("url", "")
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"token_type": "Bearer",
"expires_in": 3600,
},
)
with patch(
"homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token",
return_value="user123",
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "Watts Vision +"
assert "token" in result.get("data", {})
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "user123"
@pytest.mark.usefixtures("current_request_with_host")
async def test_invalid_token_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test the OAuth2 config flow with invalid token."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "invalid-access-token",
"token_type": "Bearer",
"expires_in": 3600,
},
)
with patch(
"homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token",
return_value=None,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "invalid_token"
@pytest.mark.usefixtures("current_request_with_host")
async def test_oauth_error(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test OAuth error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(
OAUTH2_TOKEN,
json={"error": "invalid_grant"},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "oauth_error"
@pytest.mark.usefixtures("current_request_with_host")
async def test_oauth_timeout(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test OAuth timeout handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(OAUTH2_TOKEN, exc=TimeoutError())
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "oauth_timeout"
@pytest.mark.usefixtures("current_request_with_host")
async def test_oauth_invalid_response(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test OAuth invalid response handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(OAUTH2_TOKEN, status=500, text="invalid json")
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "oauth_failed"
@pytest.mark.usefixtures("current_request_with_host")
async def test_unique_config_entry(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that duplicate config entries are not allowed."""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="user123",
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"token_type": "Bearer",
"expires_in": 3600,
},
)
with patch(
"homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token",
return_value="user123",
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1

View File

@@ -0,0 +1,275 @@
"""Test the Watts Vision integration initialization."""
from datetime import timedelta
from unittest.mock import AsyncMock
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from visionpluspython.exceptions import (
WattsVisionAuthError,
WattsVisionConnectionError,
WattsVisionDeviceError,
WattsVisionError,
WattsVisionTimeoutError,
)
from visionpluspython.models import create_device_from_data
from homeassistant.components.watts.const import (
DISCOVERY_INTERVAL_MINUTES,
DOMAIN,
OAUTH2_TOKEN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_setup_entry_success(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful setup and unload of entry."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_watts_client.discover_devices.assert_called_once()
unload_result = await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert unload_result is True
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("setup_credentials")
async def test_setup_entry_auth_failed(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup with authentication failure."""
config_entry = MockConfigEntry(
domain="watts",
unique_id="test-device-id",
data={
"device_id": "test-device-id",
"auth_implementation": "watts",
"token": {
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_at": 0, # Expired token to force refresh
},
},
)
config_entry.add_to_hass(hass)
aioclient_mock.post(OAUTH2_TOKEN, status=401)
result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert config_entry.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.usefixtures("setup_credentials")
async def test_setup_entry_not_ready(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup when network is temporarily unavailable."""
config_entry = MockConfigEntry(
domain="watts",
unique_id="test-device-id",
data={
"device_id": "test-device-id",
"auth_implementation": "watts",
"token": {
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_at": 0, # Expired token to force refresh
},
},
)
config_entry.add_to_hass(hass)
aioclient_mock.post(OAUTH2_TOKEN, exc=ClientError("Connection timeout"))
result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_hub_coordinator_update_failed(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup when hub coordinator update fails."""
# Make discover_devices fail
mock_watts_client.discover_devices.side_effect = ConnectionError("API error")
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("setup_credentials")
async def test_setup_entry_server_error_5xx(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup when server returns error."""
config_entry = MockConfigEntry(
domain="watts",
unique_id="test-device-id",
data={
"device_id": "test-device-id",
"auth_implementation": "watts",
"token": {
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_at": 0, # Expired token to force refresh
},
},
)
config_entry.add_to_hass(hass)
aioclient_mock.post(OAUTH2_TOKEN, status=500)
result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(WattsVisionAuthError("Auth failed"), ConfigEntryState.SETUP_ERROR),
(WattsVisionConnectionError("Connection lost"), ConfigEntryState.SETUP_RETRY),
(WattsVisionTimeoutError("Request timeout"), ConfigEntryState.SETUP_RETRY),
(WattsVisionDeviceError("Device error"), ConfigEntryState.SETUP_RETRY),
(WattsVisionError("API error"), ConfigEntryState.SETUP_RETRY),
(ValueError("Value error"), ConfigEntryState.SETUP_RETRY),
],
)
async def test_setup_entry_discover_devices_errors(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup errors during device discovery."""
mock_watts_client.discover_devices.side_effect = exception
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert mock_config_entry.state is expected_state
async def test_dynamic_device_creation(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices are created dynamically."""
await setup_integration(hass, mock_config_entry)
assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_123")})
assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_456")})
assert (
device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_789")})
is None
)
new_device_data = {
"deviceId": "thermostat_789",
"deviceName": "Kitchen Thermostat",
"deviceType": "thermostat",
"interface": "homeassistant.components.THERMOSTAT",
"roomName": "Kitchen",
"isOnline": True,
"currentTemperature": 21.0,
"setpoint": 20.0,
"thermostatMode": "Comfort",
"minAllowedTemperature": 5.0,
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
"availableThermostatModes": ["Program", "Eco", "Comfort", "Off"],
}
new_device = create_device_from_data(new_device_data)
current_devices = list(mock_watts_client.discover_devices.return_value)
mock_watts_client.discover_devices.return_value = [*current_devices, new_device]
freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES))
async_fire_time_changed(hass)
await hass.async_block_till_done()
new_device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "thermostat_789")}
)
assert new_device_entry is not None
assert new_device_entry.name == "Kitchen Thermostat"
state = hass.states.get("climate.kitchen_thermostat")
assert state is not None
async def test_stale_device_removal(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test stale devices are removed dynamically."""
await setup_integration(hass, mock_config_entry)
device_123 = device_registry.async_get_device(
identifiers={(DOMAIN, "thermostat_123")}
)
device_456 = device_registry.async_get_device(
identifiers={(DOMAIN, "thermostat_456")}
)
assert device_123 is not None
assert device_456 is not None
current_devices = list(mock_watts_client.discover_devices.return_value)
# remove thermostat_456
mock_watts_client.discover_devices.return_value = [
d for d in current_devices if d.device_id != "thermostat_456"
]
freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Verify thermostat_456 has been removed
device_456_after_removal = device_registry.async_get_device(
identifiers={(DOMAIN, "thermostat_456")}
)
assert device_456_after_removal is None