diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index b33a98caa88..ad326569e89 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -11,5 +11,6 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.SCENE, + Platform.SWITCH, ] LOGGER = getLogger(__package__) diff --git a/homeassistant/components/velux/switch.py b/homeassistant/components/velux/switch.py new file mode 100644 index 00000000000..d7f5dfd6803 --- /dev/null +++ b/homeassistant/components/velux/switch.py @@ -0,0 +1,53 @@ +"""Support for Velux switches.""" + +from __future__ import annotations + +from typing import Any + +from pyvlx import OnOffSwitch + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VeluxConfigEntry +from .entity import VeluxEntity, wrap_pyvlx_call_exceptions + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VeluxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch(es) for Velux platform.""" + pyvlx = config_entry.runtime_data + async_add_entities( + VeluxOnOffSwitch(node, config_entry.entry_id) + for node in pyvlx.nodes + if isinstance(node, OnOffSwitch) + ) + + +class VeluxOnOffSwitch(VeluxEntity, SwitchEntity): + """Representation of a Velux on/off switch.""" + + _attr_name = None + + node: OnOffSwitch + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.node.is_on() + + @wrap_pyvlx_call_exceptions + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.node.set_on() + + @wrap_pyvlx_call_exceptions + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.node.set_off() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 41a58ddd724..c14911bec00 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pyvlx import Blind, Light, OnOffLight, Scene, Window +from pyvlx import Blind, Light, OnOffLight, OnOffSwitch, Scene, Window from homeassistant.components.velux import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform @@ -114,6 +114,19 @@ def mock_onoff_light() -> AsyncMock: return light +# an on/off switch +@pytest.fixture +def mock_onoff_switch() -> AsyncMock: + """Create a mock Velux on/off switch.""" + switch = AsyncMock(spec=OnOffSwitch, autospec=True) + switch.name = "Test On Off Switch" + switch.serial_number = "0817" + switch.is_on.return_value = False + switch.is_off.return_value = True + switch.pyvlx = MagicMock() + return switch + + # fixture to create all other cover types via parameterization @pytest.fixture def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: @@ -133,6 +146,7 @@ def mock_pyvlx( mock_scene: AsyncMock, mock_light: AsyncMock, mock_onoff_light: AsyncMock, + mock_onoff_switch: AsyncMock, mock_window: AsyncMock, mock_blind: AsyncMock, request: pytest.FixtureRequest, @@ -152,6 +166,7 @@ def mock_pyvlx( pyvlx.nodes = [ mock_light, mock_onoff_light, + mock_onoff_switch, mock_blind, mock_window, mock_cover_type, diff --git a/tests/components/velux/snapshots/test_switch.ambr b/tests/components/velux/snapshots/test_switch.ambr new file mode 100644 index 00000000000..572919affdc --- /dev/null +++ b/tests/components/velux/snapshots/test_switch.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_switch_setup[mock_onoff_switch][switch.test_on_off_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.test_on_off_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': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0817', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[mock_onoff_switch][switch.test_on_off_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test On Off Switch', + }), + 'context': , + 'entity_id': 'switch.test_on_off_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/velux/test_switch.py b/tests/components/velux/test_switch.py new file mode 100644 index 00000000000..f71f2e45cd7 --- /dev/null +++ b/tests/components/velux/test_switch.py @@ -0,0 +1,133 @@ +"""Test Velux switch entities.""" + +from unittest.mock import AsyncMock + +import pytest +from pyvlx import PyVLXException + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import update_callback_entity + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +# Apply setup_integration fixture to all tests in this module +pytestmark = pytest.mark.usefixtures("setup_integration") + + +@pytest.fixture +def platform() -> Platform: + """Fixture to specify platform to test.""" + return Platform.SWITCH + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_onoff_switch"], indirect=True) +async def test_switch_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_pyvlx: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the entity and validate registry metadata for switch entities.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +async def test_switch_device_association( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_onoff_switch: AsyncMock, +) -> None: + """Test switch device association.""" + test_entity_id = f"switch.{mock_onoff_switch.name.lower().replace(' ', '_')}" + + entity_entry = entity_registry.async_get(test_entity_id) + assert entity_entry is not None + assert entity_entry.device_id is not None + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + + assert ("velux", mock_onoff_switch.serial_number) in device_entry.identifiers + assert device_entry.name == mock_onoff_switch.name + + +async def test_switch_is_on(hass: HomeAssistant, mock_onoff_switch: AsyncMock) -> None: + """Test switch on state.""" + entity_id = f"switch.{mock_onoff_switch.name.lower().replace(' ', '_')}" + + # Initial state is off + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + # Simulate switching on + mock_onoff_switch.is_on.return_value = True + mock_onoff_switch.is_off.return_value = False + await update_callback_entity(hass, mock_onoff_switch) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_switch_turn_on_off( + hass: HomeAssistant, mock_onoff_switch: AsyncMock +) -> None: + """Test turning switch on.""" + entity_id = f"switch.{mock_onoff_switch.name.lower().replace(' ', '_')}" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + mock_onoff_switch.set_on.assert_awaited_once() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + mock_onoff_switch.set_off.assert_awaited_once() + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_onoff_switch"], indirect=True) +async def test_switch_error_handling( + hass: HomeAssistant, mock_onoff_switch: AsyncMock +) -> None: + """Test error handling when turning switching fails.""" + entity_id = f"switch.{mock_onoff_switch.name.lower().replace(' ', '_')}" + mock_onoff_switch.set_on.side_effect = PyVLXException("Connection lost") + mock_onoff_switch.set_off.side_effect = PyVLXException("Connection lost") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + )