From ca30d8b1c2dbc96065dee7a8e06faafb2ff4569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Mon, 22 Dec 2025 15:12:28 +0100 Subject: [PATCH] Add support for load switches to WMS WebControl pro (#151047) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wmspro/__init__.py | 1 + homeassistant/components/wmspro/switch.py | 62 +++++ tests/components/wmspro/conftest.py | 20 ++ .../fixtures/config_prod_load_switch.json | 249 ++++++++++++++++++ .../fixtures/status_prod_load_switch.json | 28 ++ .../wmspro/snapshots/test_switch.ambr | 46 ++++ tests/components/wmspro/test_switch.py | 127 +++++++++ 7 files changed, 533 insertions(+) create mode 100644 homeassistant/components/wmspro/switch.py create mode 100644 tests/components/wmspro/fixtures/config_prod_load_switch.json create mode 100644 tests/components/wmspro/fixtures/status_prod_load_switch.json create mode 100644 tests/components/wmspro/snapshots/test_switch.ambr create mode 100644 tests/components/wmspro/test_switch.py diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index ebfdf5b8b34..4091278d06d 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -20,6 +20,7 @@ PLATFORMS: list[Platform] = [ Platform.COVER, Platform.LIGHT, Platform.SCENE, + Platform.SWITCH, ] type WebControlProConfigEntry = ConfigEntry[WebControlPro] diff --git a/homeassistant/components/wmspro/switch.py b/homeassistant/components/wmspro/switch.py new file mode 100644 index 00000000000..0e188aa1f22 --- /dev/null +++ b/homeassistant/components/wmspro/switch.py @@ -0,0 +1,62 @@ +"""Support for loads connected with WMS WebControl pro.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_responseType, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WebControlProConfigEntry +from .entity import WebControlProGenericEntity + +SCAN_INTERVAL = timedelta(seconds=15) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WebControlProConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WMS based switches from a config entry.""" + hub = config_entry.runtime_data + + async_add_entities( + WebControlProSwitch(config_entry.entry_id, dest) + for dest in hub.dests.values() + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LoadSwitch) + ) + + +class WebControlProSwitch(WebControlProGenericEntity, SwitchEntity): + """Representation of a WMS based switch.""" + + _attr_name = None + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LoadSwitch) + return action["onOffState"] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LoadSwitch) + await action( + onOffState=True, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LoadSwitch) + await action( + onOffState=False, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 97326773dc0..480fb4669c6 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -82,6 +82,16 @@ def mock_hub_configuration_prod_awning_valance() -> Generator[AsyncMock]: yield mock_hub_configuration +@pytest.fixture +def mock_hub_configuration_prod_load_switch() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture("config_prod_load_switch.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + @pytest.fixture def mock_hub_configuration_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getConfiguration.""" @@ -114,6 +124,16 @@ def mock_hub_status_prod_dimmer() -> Generator[AsyncMock]: yield mock_hub_status +@pytest.fixture +def mock_hub_status_prod_load_switch() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture("status_prod_load_switch.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status + + @pytest.fixture def mock_hub_status_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getStatus.""" diff --git a/tests/components/wmspro/fixtures/config_prod_load_switch.json b/tests/components/wmspro/fixtures/config_prod_load_switch.json new file mode 100644 index 00000000000..df15d18b121 --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_load_switch.json @@ -0,0 +1,249 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 65355, + "animationType": 3, + "names": ["DACH", "", "", ""], + "actions": [ + { + "id": 6, + "actionType": 2, + "actionDescription": 3, + "minValue": -127, + "maxValue": 127 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + }, + { + "id": 23, + "actionType": 7, + "actionDescription": 12 + } + ] + }, + { + "id": 90732, + "animationType": 1, + "names": ["MARKISE LINKS", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 159890, + "animationType": 1, + "names": ["MARKISE VORNE", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 201106, + "animationType": 1, + "names": ["MARKISE RECHTS", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 317448, + "animationType": 6, + "names": ["LICHT SENKRECHT", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 8, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 17, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 382168, + "animationType": 6, + "names": ["LICHT OBEN", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 8, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 17, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 414040, + "animationType": 6, + "names": ["LICHT UNTEN", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 8, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 17, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 499120, + "animationType": 5, + "names": ["HEIZUNG LINKS", "", "", ""], + "actions": [ + { + "id": 20, + "actionType": 4, + "actionDescription": 7 + }, + { + "id": 21, + "actionType": 5, + "actionDescription": 10 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 533844, + "animationType": 5, + "names": ["HEIZUNG RECHTS", "", "", ""], + "actions": [ + { + "id": 20, + "actionType": 4, + "actionDescription": 7 + }, + { + "id": 21, + "actionType": 5, + "actionDescription": 10 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 39443, + "name": "Terasse", + "destinations": [ + 65355, 90732, 159890, 201106, 317448, 382168, 414040, 499120, 533844 + ], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/status_prod_load_switch.json b/tests/components/wmspro/fixtures/status_prod_load_switch.json new file mode 100644 index 00000000000..2998a18928c --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_load_switch.json @@ -0,0 +1,28 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 499120, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 20, + "value": { + "onOffState": false + } + }, + { + "actionId": 21, + "value": { + "onOffState": false + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_switch.ambr b/tests/components/wmspro/snapshots/test_switch.ambr new file mode 100644 index 00000000000..16d37d85fe8 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_switch.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_switch_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'terasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '499120', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Switch', + 'model_id': None, + 'name': 'HEIZUNG LINKS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '499120', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_switch_update + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by WMS WebControl pro API', + 'friendly_name': 'HEIZUNG LINKS', + }), + 'context': , + 'entity_id': 'switch.heizung_links', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/wmspro/test_switch.py b/tests/components/wmspro/test_switch.py new file mode 100644 index 00000000000..823d553a44b --- /dev/null +++ b/tests/components/wmspro/test_switch.py @@ -0,0 +1,127 @@ +"""Test the wmspro switch support.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.components.wmspro.switch import SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_config_entry + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_switch_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_load_switch: AsyncMock, + mock_hub_status_prod_load_switch: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that a switch device is created correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod_load_switch.mock_calls) == 1 + assert len(mock_hub_status_prod_load_switch.mock_calls) >= 2 + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "499120")}) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_switch_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_load_switch: AsyncMock, + mock_hub_status_prod_load_switch: AsyncMock, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test that a switch entity is created and updated correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod_load_switch.mock_calls) == 1 + assert len(mock_hub_status_prod_load_switch.mock_calls) >= 2 + + entity = hass.states.get("switch.heizung_links") + assert entity is not None + assert entity == snapshot + + before = len(mock_hub_status_prod_load_switch.mock_calls) + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_hub_status_prod_load_switch.mock_calls) > before + + +async def test_switch_turn_on_and_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_load_switch: AsyncMock, + mock_hub_status_prod_load_switch: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a switch entity is turned on and off correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod_load_switch.mock_calls) == 1 + assert len(mock_hub_status_prod_load_switch.mock_calls) >= 1 + + entity = hass.states.get("switch.heizung_links") + assert entity is not None + assert entity.state == STATE_OFF + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_load_switch.mock_calls) + + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("switch.heizung_links") + assert entity is not None + assert entity.state == STATE_ON + assert len(mock_hub_status_prod_load_switch.mock_calls) == before + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_load_switch.mock_calls) + + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("switch.heizung_links") + assert entity is not None + assert entity.state == STATE_OFF + assert len(mock_hub_status_prod_load_switch.mock_calls) == before