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:
@@ -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
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
160
homeassistant/components/watts/__init__.py
Normal file
160
homeassistant/components/watts/__init__.py
Normal 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)
|
||||
12
homeassistant/components/watts/application_credentials.py
Normal file
12
homeassistant/components/watts/application_credentials.py
Normal 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)
|
||||
164
homeassistant/components/watts/climate.py
Normal file
164
homeassistant/components/watts/climate.py
Normal 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()
|
||||
50
homeassistant/components/watts/config_flow.py
Normal file
50
homeassistant/components/watts/config_flow.py
Normal 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,
|
||||
)
|
||||
37
homeassistant/components/watts/const.py
Normal file
37
homeassistant/components/watts/const.py
Normal 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,
|
||||
}
|
||||
228
homeassistant/components/watts/coordinator.py
Normal file
228
homeassistant/components/watts/coordinator.py
Normal 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
|
||||
)
|
||||
43
homeassistant/components/watts/entity.py
Normal file
43
homeassistant/components/watts/entity.py
Normal 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
|
||||
11
homeassistant/components/watts/manifest.json
Normal file
11
homeassistant/components/watts/manifest.json
Normal 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"]
|
||||
}
|
||||
72
homeassistant/components/watts/quality_scale.yaml
Normal file
72
homeassistant/components/watts/quality_scale.yaml
Normal 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
|
||||
33
homeassistant/components/watts/strings.json
Normal file
33
homeassistant/components/watts/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"tesla_fleet",
|
||||
"twitch",
|
||||
"volvo",
|
||||
"watts",
|
||||
"weheat",
|
||||
"withings",
|
||||
"xbox",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -751,6 +751,7 @@ FLOWS = {
|
||||
"wallbox",
|
||||
"waqi",
|
||||
"watergate",
|
||||
"watts",
|
||||
"watttime",
|
||||
"waze_travel_time",
|
||||
"weatherflow",
|
||||
|
||||
@@ -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
10
mypy.ini
generated
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
15
tests/components/watts/__init__.py
Normal file
15
tests/components/watts/__init__.py
Normal 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()
|
||||
103
tests/components/watts/conftest.py
Normal file
103
tests/components/watts/conftest.py
Normal 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,
|
||||
)
|
||||
22
tests/components/watts/fixtures/device_detail.json
Normal file
22
tests/components/watts/fixtures/device_detail.json
Normal 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"
|
||||
]
|
||||
}
|
||||
39
tests/components/watts/fixtures/device_report.json
Normal file
39
tests/components/watts/fixtures/device_report.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
39
tests/components/watts/fixtures/discover_devices.json
Normal file
39
tests/components/watts/fixtures/discover_devices.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
133
tests/components/watts/snapshots/test_climate.ambr
Normal file
133
tests/components/watts/snapshots/test_climate.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
15
tests/components/watts/test_application_credentials.py
Normal file
15
tests/components/watts/test_application_credentials.py
Normal 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
|
||||
264
tests/components/watts/test_climate.py
Normal file
264
tests/components/watts/test_climate.py
Normal 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,
|
||||
)
|
||||
247
tests/components/watts/test_config_flow.py
Normal file
247
tests/components/watts/test_config_flow.py
Normal 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
|
||||
275
tests/components/watts/test_init.py
Normal file
275
tests/components/watts/test_init.py
Normal 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
|
||||
Reference in New Issue
Block a user