Add switch entities to Watts Vision + (#162699)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
theobld-ww
2026-02-11 17:39:17 +01:00
committed by GitHub
parent 4e46431798
commit 0c1af1d613
11 changed files with 390 additions and 40 deletions

View File

@@ -9,7 +9,6 @@ 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
@@ -18,7 +17,7 @@ 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 .const import DOMAIN, SUPPORTED_DEVICE_TYPES
from .coordinator import (
WattsVisionDeviceCoordinator,
WattsVisionDeviceData,
@@ -27,7 +26,7 @@ from .coordinator import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH]
@dataclass
@@ -63,7 +62,7 @@ def _handle_new_devices(
for device_id in new_device_ids:
device = hub_coordinator.data[device_id]
if not isinstance(device, ThermostatDevice):
if not isinstance(device, SUPPORTED_DEVICE_TYPES):
continue
device_coordinator = WattsVisionDeviceCoordinator(
@@ -124,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry)
device_coordinators: dict[str, WattsVisionDeviceCoordinator] = {}
for device_id in hub_coordinator.device_ids:
device = hub_coordinator.data[device_id]
if not isinstance(device, ThermostatDevice):
if not isinstance(device, SUPPORTED_DEVICE_TYPES):
continue
device_coordinator = WattsVisionDeviceCoordinator(

View File

@@ -1,6 +1,6 @@
"""Constants for the Watts Vision+ integration."""
from visionpluspython.models import ThermostatMode
from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode
from homeassistant.components.climate import HVACMode
@@ -35,3 +35,5 @@ HVAC_MODE_TO_THERMOSTAT = {
HVACMode.OFF: ThermostatMode.OFF,
HVACMode.AUTO: ThermostatMode.PROGRAM,
}
SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice)

View File

@@ -27,6 +27,9 @@
"set_hvac_mode_error": {
"message": "An error occurred while setting the HVAC mode."
},
"set_switch_state_error": {
"message": "An error occurred while setting the switch state."
},
"set_temperature_error": {
"message": "An error occurred while setting the temperature."
}

View File

@@ -0,0 +1,129 @@
"""Switch platform for Watts Vision integration."""
from __future__ import annotations
import logging
from typing import Any
from visionpluspython.models import SwitchDevice
from homeassistant.components.switch import SwitchEntity
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
from .coordinator import WattsVisionDeviceCoordinator
from .entity import WattsVisionEntity
_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 switch entities from a config entry."""
device_coordinators = entry.runtime_data.device_coordinators
known_device_ids: set[str] = set()
@callback
def _check_new_switches() -> None:
"""Check for new switch devices."""
switch_coords = {
did: coord
for did, coord in device_coordinators.items()
if isinstance(coord.data.device, SwitchDevice)
}
current_device_ids = set(switch_coords.keys())
new_device_ids = current_device_ids - known_device_ids
if not new_device_ids:
return
_LOGGER.debug(
"Adding switch entities for %d new switch(es)",
len(new_device_ids),
)
new_entities = []
for device_id in new_device_ids:
coord = switch_coords[device_id]
device = coord.data.device
assert isinstance(device, SwitchDevice)
new_entities.append(WattsVisionSwitch(coord, device))
known_device_ids.update(new_device_ids)
async_add_entities(new_entities)
_check_new_switches()
entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{entry.entry_id}_new_device",
_check_new_switches,
)
)
class WattsVisionSwitch(WattsVisionEntity[SwitchDevice], SwitchEntity):
"""Representation of a Watts Vision switch."""
_attr_name = None
def __init__(
self,
coordinator: WattsVisionDeviceCoordinator,
switch: SwitchDevice,
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator, switch.device_id)
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.device.is_turned_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.coordinator.client.set_switch_state(self.device_id, True)
except RuntimeError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_switch_state_error",
) from err
_LOGGER.debug(
"Successfully turned on switch %s",
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
try:
await self.coordinator.client.set_switch_state(self.device_id, False)
except RuntimeError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_switch_state_error",
) from err
_LOGGER.debug(
"Successfully turned off switch %s",
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()

View File

@@ -4,7 +4,7 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from visionpluspython.models import create_device_from_data
from visionpluspython.models import Device, create_device_from_data
from homeassistant.components.application_credentials import (
ClientCredential,
@@ -64,6 +64,7 @@ def mock_watts_client() -> Generator[AsyncMock]:
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)
switch_detail_data = load_json_object_fixture("switch_detail.json", DOMAIN)
discovered_devices = [
create_device_from_data(device_data) # type: ignore[arg-type]
@@ -74,10 +75,22 @@ def mock_watts_client() -> Generator[AsyncMock]:
for device_id, device_data in device_report_data.items()
}
device_detail = create_device_from_data(device_detail_data) # type: ignore[arg-type]
switch_detail = create_device_from_data(switch_detail_data) # type: ignore[arg-type]
device_details = {
device_detail_data["deviceId"]: device_detail,
switch_detail_data["deviceId"]: switch_detail,
}
async def get_device_side_effect(
device_id: str, refresh: bool = False
) -> Device:
"""Return the appropriate device based on device_id."""
return device_details.get(device_id, device_detail)
client.discover_devices.return_value = discovered_devices
client.get_devices_report.return_value = device_report
client.get_device.return_value = device_detail
client.get_device.side_effect = get_device_side_effect
yield client

View File

@@ -35,5 +35,14 @@
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
"availableThermostatModes": ["Program", "Eco", "Comfort", "Off"]
},
"switch_789": {
"deviceId": "switch_789",
"deviceName": "Living Room Switch",
"deviceType": "switch",
"interface": "homeassistant.components.SWITCH",
"roomName": "Living Room",
"isOnline": true,
"isTurnedOn": false
}
}

View File

@@ -35,5 +35,14 @@
"maxAllowedTemperature": 30.0,
"temperatureUnit": "C",
"availableThermostatModes": ["Program", "Eco", "Comfort", "Off"]
},
{
"deviceId": "switch_789",
"deviceName": "Living Room Switch",
"deviceType": "switch",
"interface": "homeassistant.components.SWITCH",
"roomName": "Living Room",
"isOnline": true,
"isTurnedOn": false
}
]

View File

@@ -0,0 +1,9 @@
{
"deviceId": "switch_789",
"deviceName": "Living Room Switch",
"deviceType": "switch",
"interface": "homeassistant.components.SWITCH",
"roomName": "Living Room",
"isOnline": true,
"isTurnedOn": true
}

View File

@@ -0,0 +1,50 @@
# serializer version: 1
# name: test_entities[switch.living_room_switch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.living_room_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 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': 0,
'translation_key': None,
'unique_id': 'switch_789',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.living_room_switch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Living Room Switch',
}),
'context': <ANY>,
'entity_id': 'switch.living_room_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -67,13 +67,13 @@ async def test_set_temperature(
)
async def test_set_temperature_triggers_fast_polling(
async def test_fast_polling(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that setting temperature triggers fast polling."""
"""Test that setting temperature triggers fast polling and it stops after duration."""
await setup_integration(hass, mock_config_entry)
# Trigger fast polling
@@ -87,10 +87,9 @@ async def test_set_temperature_triggers_fast_polling(
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)
# Fast polling should be active
freezer.tick(timedelta(seconds=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -98,33 +97,9 @@ async def test_set_temperature_triggers_fast_polling(
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
# Should still be in fast polling after 55s
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))
freezer.tick(timedelta(seconds=50))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -135,8 +110,6 @@ async def test_fast_polling_stops_after_duration(
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))

View File

@@ -0,0 +1,154 @@
"""Tests for the Watts Vision switch platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
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 switch entities."""
with patch("homeassistant.components.watts.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_switch_state(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test switch state."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("switch.living_room_switch")
assert state is not None
assert state.state == STATE_OFF
@pytest.mark.parametrize(
("service", "expected_state"),
[
(SERVICE_TURN_ON, True),
(SERVICE_TURN_OFF, False),
],
)
async def test_turn_on_off(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
expected_state: bool,
) -> None:
"""Test turning switch on and off."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
mock_watts_client.set_switch_state.assert_called_once_with(
"switch_789", expected_state
)
async def test_fast_polling(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that turning on triggers fast polling and it stops after duration."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
mock_watts_client.get_device.reset_mock()
# Fast polling should be active
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("switch_789", refresh=True)
# Should still be in fast polling after 55s
mock_watts_client.get_device.reset_mock()
freezer.tick(timedelta(seconds=50))
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()
# 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
@pytest.mark.parametrize(
"service",
[SERVICE_TURN_ON, SERVICE_TURN_OFF],
)
async def test_api_error(
hass: HomeAssistant,
mock_watts_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
) -> None:
"""Test error handling when turning on/off fails."""
await setup_integration(hass, mock_config_entry)
mock_watts_client.set_switch_state.side_effect = RuntimeError("API Error")
with pytest.raises(
HomeAssistantError, match="An error occurred while setting the switch state"
):
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)