-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add Watts Vision + integration with tests #153022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
65 commits
Select commit
Hold shift + click to select a range
f49e2e9
Add Watts Vision + integration with tests
theobld-ww 91da4f5
Merge branch 'dev' into watts_vision
theobld-ww cc954f3
Merge branch 'dev' into watts_vision
theobld-ww d296bbe
Remove switch platform
theobld-ww 245c1b8
Merge branch 'dev' into watts_vision
theobld-ww 58e5843
Use dataclass instead of Dict
theobld-ww f83ac9f
Merge branch 'dev' into watts_vision
theobld-ww 824509d
Merge branch 'dev' into watts_vision
theobld-ww ea2cc12
Update tests/components/watts/test_coordinator.py
theobld-ww cb8c840
Merge branch 'dev' into watts_vision
theobld-ww a4ec963
Merge branch 'dev' into watts_vision
theobld-ww bc4d3bd
Simplify HA integration, update tests
theobld-ww f1f76aa
Use HA http session
theobld-ww c0bb5fc
Merge branch 'dev' into watts_vision
theobld-ww 5571d2a
Do not check instance type of thermostat, update tests
theobld-ww c5c779d
Merge branch 'dev' into watts_vision
theobld-ww dbd75c4
Use device id instead of name
theobld-ww 49d007e
Update homeassistant/components/watts/climate.py
theobld-ww 7177484
Update homeassistant/components/watts/climate.py
theobld-ww a37ffd0
Merge branch 'dev' into watts_vision
theobld-ww 03d1357
Use HomeAssistantError
theobld-ww 104a898
Merge branch 'dev' into watts_vision
theobld-ww b431541
HubCoordinator for bulk update and device coordinator for individual …
theobld-ww 7bb5186
Add fast pooling after command
theobld-ww ceed507
Merge branch 'dev' into watts_vision
theobld-ww 70a028f
Merge branch 'dev' into watts_vision
theobld-ww d63ebdb
Update homeassistant/components/watts/climate.py
theobld-ww c4b4363
Update homeassistant/components/watts/climate.py
theobld-ww 933120d
Update homeassistant/components/watts/coordinator.py
theobld-ww 7840ece
Merge branch 'dev' into watts_vision
theobld-ww cb80a50
Imrpove new coordinator functions
theobld-ww 89bc534
Create snapshot tests and remove old coordinators tests
theobld-ww a878119
Update quality scale
theobld-ww 6135ecd
Merge branch 'dev' into watts_vision
theobld-ww cdc7dc3
Merge branch 'dev' into watts_vision
theobld-ww cc52bac
Implemente config-entry-unloading by unsubscribe listeners
theobld-ww f72e814
Merge branch 'dev' into watts_vision
theobld-ww 6f61e86
support parallel update
theobld-ww 8305ba1
Support oauth reauthentication
theobld-ww d1566c7
Increase tests coverage
theobld-ww 1654167
Use WattsVisionConfigEntry
theobld-ww a21c8c8
Use setup func for first discovery
theobld-ww 463542e
Merge branch 'dev' into watts_vision
theobld-ww b5ca70e
Remove unnecessary condition and type checking fix
theobld-ww ec22593
Simplify tests
theobld-ww a6ba380
remove unused tests patch
theobld-ww 130ee12
use ha json funcs
theobld-ww 8a8c3d8
Merge branch 'dev' into watts_vision
theobld-ww 224db5f
Improve tests and mocking
theobld-ww 12a6d22
Merge branch 'dev' into watts_vision
theobld-ww 271f3a8
Implement oauth ConfigEntryNotReady
theobld-ww bdafd33
Add diagnostics support
theobld-ww 171a32d
Dynamic and stale devices
theobld-ww 76dac97
Merge branch 'dev' into watts_vision
theobld-ww add64e4
Add strict typing support
theobld-ww f7b6aa3
remove unused file
theobld-ww d91e261
Add translations and improve docs
theobld-ww 8e11978
Set quality scale to platinium
theobld-ww fbbb98d
Merge branch 'dev' into watts_vision
theobld-ww 3b43cc2
Remove reauth and diagnotics
theobld-ww c8d512f
Fix typo and auth error
theobld-ww 4da182d
Merge discovery logic into async update data
theobld-ww b2cf647
Remove unused function to remove device
theobld-ww e852d90
Update homeassistant/components/watts/quality_scale.yaml
theobld-ww ea4fa67
Add / removal discovery tests
theobld-ww File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| """Application credentials for Watts integration.""" | ||
|
theobld-ww marked this conversation as resolved.
|
||
|
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
theobld-ww marked this conversation as resolved.
|
||
|
|
||
| _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() | ||
|
theobld-ww marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.