diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 38b84c920b8..18abe77fb4b 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -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( diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index 8434daca11d..508f24bde0b 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -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) diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index eb70ea96d1f..d7a38341abe 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -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." } diff --git a/homeassistant/components/watts/switch.py b/homeassistant/components/watts/switch.py new file mode 100644 index 00000000000..4b3a2526478 --- /dev/null +++ b/homeassistant/components/watts/switch.py @@ -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() diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index 89a20cb56ab..cf44e90db26 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -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 diff --git a/tests/components/watts/fixtures/device_report.json b/tests/components/watts/fixtures/device_report.json index bf3467e769e..87a37fcb4c3 100644 --- a/tests/components/watts/fixtures/device_report.json +++ b/tests/components/watts/fixtures/device_report.json @@ -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 } } diff --git a/tests/components/watts/fixtures/discover_devices.json b/tests/components/watts/fixtures/discover_devices.json index 0bb36039918..3b6d9cff83b 100644 --- a/tests/components/watts/fixtures/discover_devices.json +++ b/tests/components/watts/fixtures/discover_devices.json @@ -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 } ] diff --git a/tests/components/watts/fixtures/switch_detail.json b/tests/components/watts/fixtures/switch_detail.json new file mode 100644 index 00000000000..2c7a03365c8 --- /dev/null +++ b/tests/components/watts/fixtures/switch_detail.json @@ -0,0 +1,9 @@ +{ + "deviceId": "switch_789", + "deviceName": "Living Room Switch", + "deviceType": "switch", + "interface": "homeassistant.components.SWITCH", + "roomName": "Living Room", + "isOnline": true, + "isTurnedOn": true +} diff --git a/tests/components/watts/snapshots/test_switch.ambr b/tests/components/watts/snapshots/test_switch.ambr new file mode 100644 index 00000000000..fab6681d1ba --- /dev/null +++ b/tests/components/watts/snapshots/test_switch.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.living_room_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'switch.living_room_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py index aa8b40aec0f..fdd3471c615 100644 --- a/tests/components/watts/test_climate.py +++ b/tests/components/watts/test_climate.py @@ -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)) diff --git a/tests/components/watts/test_switch.py b/tests/components/watts/test_switch.py new file mode 100644 index 00000000000..b3bfd2b9585 --- /dev/null +++ b/tests/components/watts/test_switch.py @@ -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, + )