Skip to content
Merged
Show file tree
Hide file tree
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 Sep 26, 2025
91da4f5
Merge branch 'dev' into watts_vision
theobld-ww Sep 26, 2025
cc954f3
Merge branch 'dev' into watts_vision
theobld-ww Sep 26, 2025
d296bbe
Remove switch platform
theobld-ww Sep 26, 2025
245c1b8
Merge branch 'dev' into watts_vision
theobld-ww Sep 26, 2025
58e5843
Use dataclass instead of Dict
theobld-ww Sep 29, 2025
f83ac9f
Merge branch 'dev' into watts_vision
theobld-ww Sep 29, 2025
824509d
Merge branch 'dev' into watts_vision
theobld-ww Sep 29, 2025
ea2cc12
Update tests/components/watts/test_coordinator.py
theobld-ww Sep 29, 2025
cb8c840
Merge branch 'dev' into watts_vision
theobld-ww Sep 29, 2025
a4ec963
Merge branch 'dev' into watts_vision
theobld-ww Oct 1, 2025
bc4d3bd
Simplify HA integration, update tests
theobld-ww Oct 7, 2025
f1f76aa
Use HA http session
theobld-ww Oct 7, 2025
c0bb5fc
Merge branch 'dev' into watts_vision
theobld-ww Oct 7, 2025
5571d2a
Do not check instance type of thermostat, update tests
theobld-ww Oct 8, 2025
c5c779d
Merge branch 'dev' into watts_vision
theobld-ww Oct 8, 2025
dbd75c4
Use device id instead of name
theobld-ww Oct 8, 2025
49d007e
Update homeassistant/components/watts/climate.py
theobld-ww Oct 9, 2025
7177484
Update homeassistant/components/watts/climate.py
theobld-ww Oct 9, 2025
a37ffd0
Merge branch 'dev' into watts_vision
theobld-ww Oct 9, 2025
03d1357
Use HomeAssistantError
theobld-ww Oct 10, 2025
104a898
Merge branch 'dev' into watts_vision
theobld-ww Oct 13, 2025
b431541
HubCoordinator for bulk update and device coordinator for individual …
theobld-ww Oct 14, 2025
7bb5186
Add fast pooling after command
theobld-ww Oct 14, 2025
ceed507
Merge branch 'dev' into watts_vision
theobld-ww Oct 14, 2025
70a028f
Merge branch 'dev' into watts_vision
theobld-ww Oct 14, 2025
d63ebdb
Update homeassistant/components/watts/climate.py
theobld-ww Oct 14, 2025
c4b4363
Update homeassistant/components/watts/climate.py
theobld-ww Oct 14, 2025
933120d
Update homeassistant/components/watts/coordinator.py
theobld-ww Oct 14, 2025
7840ece
Merge branch 'dev' into watts_vision
theobld-ww Oct 16, 2025
cb80a50
Imrpove new coordinator functions
theobld-ww Oct 16, 2025
89bc534
Create snapshot tests and remove old coordinators tests
theobld-ww Oct 16, 2025
a878119
Update quality scale
theobld-ww Oct 20, 2025
6135ecd
Merge branch 'dev' into watts_vision
theobld-ww Oct 20, 2025
cdc7dc3
Merge branch 'dev' into watts_vision
theobld-ww Nov 3, 2025
cc52bac
Implemente config-entry-unloading by unsubscribe listeners
theobld-ww Nov 12, 2025
f72e814
Merge branch 'dev' into watts_vision
theobld-ww Nov 12, 2025
6f61e86
support parallel update
theobld-ww Nov 12, 2025
8305ba1
Support oauth reauthentication
theobld-ww Nov 13, 2025
d1566c7
Increase tests coverage
theobld-ww Nov 13, 2025
1654167
Use WattsVisionConfigEntry
theobld-ww Nov 13, 2025
a21c8c8
Use setup func for first discovery
theobld-ww Nov 13, 2025
463542e
Merge branch 'dev' into watts_vision
theobld-ww Nov 13, 2025
b5ca70e
Remove unnecessary condition and type checking fix
theobld-ww Nov 13, 2025
ec22593
Simplify tests
theobld-ww Nov 13, 2025
a6ba380
remove unused tests patch
theobld-ww Nov 13, 2025
130ee12
use ha json funcs
theobld-ww Nov 13, 2025
8a8c3d8
Merge branch 'dev' into watts_vision
theobld-ww Dec 9, 2025
224db5f
Improve tests and mocking
theobld-ww Dec 9, 2025
12a6d22
Merge branch 'dev' into watts_vision
theobld-ww Dec 9, 2025
271f3a8
Implement oauth ConfigEntryNotReady
theobld-ww Dec 10, 2025
bdafd33
Add diagnostics support
theobld-ww Dec 10, 2025
171a32d
Dynamic and stale devices
theobld-ww Dec 15, 2025
76dac97
Merge branch 'dev' into watts_vision
theobld-ww Dec 17, 2025
add64e4
Add strict typing support
theobld-ww Dec 17, 2025
f7b6aa3
remove unused file
theobld-ww Dec 17, 2025
d91e261
Add translations and improve docs
theobld-ww Dec 17, 2025
8e11978
Set quality scale to platinium
theobld-ww Dec 17, 2025
fbbb98d
Merge branch 'dev' into watts_vision
theobld-ww Dec 18, 2025
3b43cc2
Remove reauth and diagnotics
theobld-ww Dec 18, 2025
c8d512f
Fix typo and auth error
theobld-ww Dec 18, 2025
4da182d
Merge discovery logic into async update data
theobld-ww Dec 18, 2025
b2cf647
Remove unused function to remove device
theobld-ww Dec 18, 2025
e852d90
Update homeassistant/components/watts/quality_scale.yaml
theobld-ww Dec 18, 2025
ea4fa67
Add / removal discovery tests
theobld-ww Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

160 changes: 160 additions & 0 deletions homeassistant/components/watts/__init__.py
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,
)
Comment thread
theobld-ww marked this conversation as resolved.

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 changes: 12 additions & 0 deletions homeassistant/components/watts/application_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Application credentials for Watts integration."""
Comment thread
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)
164 changes: 164 additions & 0 deletions homeassistant/components/watts/climate.py
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
Comment thread
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()
Comment thread
theobld-ww marked this conversation as resolved.
Loading
Loading