mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 05:06:13 +01:00
Add switch entities to Watts Vision + (#162699)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
129
homeassistant/components/watts/switch.py
Normal file
129
homeassistant/components/watts/switch.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
9
tests/components/watts/fixtures/switch_detail.json
Normal file
9
tests/components/watts/fixtures/switch_detail.json
Normal 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
|
||||
}
|
||||
50
tests/components/watts/snapshots/test_switch.ambr
Normal file
50
tests/components/watts/snapshots/test_switch.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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))
|
||||
|
||||
154
tests/components/watts/test_switch.py
Normal file
154
tests/components/watts/test_switch.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user