From 562f27d7cb8005028706f1dc0971cdb1a111eab7 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Thu, 10 Sep 2026 16:10:46 +0200 Subject: [PATCH] Add Button platform to my-PV integration (#181835) --- homeassistant/components/my_pv/__init__.py | 1 + homeassistant/components/my_pv/button.py | 58 +++++++ homeassistant/components/my_pv/coordinator.py | 9 + homeassistant/components/my_pv/entity.py | 16 +- tests/components/my_pv/conftest.py | 12 ++ .../my_pv/snapshots/test_button.ambr | 52 ++++++ tests/components/my_pv/test_button.py | 155 ++++++++++++++++++ 7 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/my_pv/button.py create mode 100644 tests/components/my_pv/snapshots/test_button.ambr create mode 100644 tests/components/my_pv/test_button.py diff --git a/homeassistant/components/my_pv/__init__.py b/homeassistant/components/my_pv/__init__.py index 46c705e601d9..0f8722bb56c9 100644 --- a/homeassistant/components/my_pv/__init__.py +++ b/homeassistant/components/my_pv/__init__.py @@ -11,6 +11,7 @@ from .const import DOMAIN from .coordinator import MyPVConfigEntry, MyPVCoordinator PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/my_pv/button.py b/homeassistant/components/my_pv/button.py new file mode 100644 index 000000000000..1583412e450f --- /dev/null +++ b/homeassistant/components/my_pv/button.py @@ -0,0 +1,58 @@ +# pylint: disable=duplicate-code +"""Creates Button entities for the my-PV Home Assistant integration.""" + +from typing import Any, override + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MyPVConfigEntry +from .const import DOMAIN +from .entity import MyPVBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MyPVConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the my-PV button.""" + coordinator = config_entry.runtime_data + entities = [] + + config = coordinator.device.get_command_configuration("reboot_device") + if config and config.get("type") in ["any", "fixed"]: + entity_description = ButtonEntityDescription( + key="reboot_device", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + ) + entities.append( + MyPVCommandButton( + coordinator, + entity_description, + coordinator.device.serial_number, + ) + ) + + async_add_entities(entities) + + +class MyPVCommandButton(MyPVBaseEntity, ButtonEntity): + """Base my-PV Button.""" + + @override + async def async_press(self, **kwargs: Any) -> None: + """Handle the button press.""" + + if not await self.coordinator.send_command(self.entity_description.key): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unknown_error" + ) diff --git a/homeassistant/components/my_pv/coordinator.py b/homeassistant/components/my_pv/coordinator.py index 2c1413edd424..304043b53a72 100644 --- a/homeassistant/components/my_pv/coordinator.py +++ b/homeassistant/components/my_pv/coordinator.py @@ -144,6 +144,15 @@ class MyPVCoordinator(DataUpdateCoordinator[None]): self.async_update_listeners() return result + @_my_pv_connection + async def send_command( + self, key: str, value: bool | float | str | None = None + ) -> bool: + """Send command.""" + result = await self.device.send_command(key, value) + self.async_update_listeners() + return result + @_my_pv_connection async def turn_on(self) -> bool: """Turn on the device.""" diff --git a/homeassistant/components/my_pv/entity.py b/homeassistant/components/my_pv/entity.py index 39a1784d00a2..247a2a08babf 100644 --- a/homeassistant/components/my_pv/entity.py +++ b/homeassistant/components/my_pv/entity.py @@ -8,8 +8,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import MyPVCoordinator -class MyPVDataEntity(CoordinatorEntity[MyPVCoordinator]): - """The my-PV data entity.""" +class MyPVBaseEntity(CoordinatorEntity[MyPVCoordinator]): + """The my-PV base entity.""" _attr_has_entity_name = True @@ -35,6 +35,18 @@ class MyPVDataEntity(CoordinatorEntity[MyPVCoordinator]): super().available and self.coordinator.device.connected and self.coordinator.device.is_on is not None + ) + + +class MyPVDataEntity(MyPVBaseEntity): + """The my-PV data entity.""" + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available and self.coordinator.device.get_data_value(self.entity_description.key) is not None ) diff --git a/tests/components/my_pv/conftest.py b/tests/components/my_pv/conftest.py index 7826330dd482..e26001a92c39 100644 --- a/tests/components/my_pv/conftest.py +++ b/tests/components/my_pv/conftest.py @@ -16,11 +16,17 @@ SETUP_CONFIGURATION = { "ww1target": {"step": 0.1, "unit": "°C", "min": 5.0, "max": 95.0} } +COMMAND_CONFIGURATION = {"reboot_device": {"type": "any"}} + def _setup_configuration_lookup(key): return SETUP_CONFIGURATION.get(key) +def _command_configuration_lookup(key): + return COMMAND_CONFIGURATION.get(key) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the my-PV mocked config entry for local devices.""" @@ -74,5 +80,11 @@ def mock_my_pv_client() -> Generator[AsyncMock]: client.current_temperature = 54.3 client.target_temperature = 62.1 client.get_setup_configuration = Mock(side_effect=_setup_configuration_lookup) + client.get_command_configuration = Mock( + side_effect=_command_configuration_lookup + ) + client.connected = True + client.is_on = True + client.send_command = AsyncMock(return_value=True) yield client diff --git a/tests/components/my_pv/snapshots/test_button.ambr b/tests/components/my_pv/snapshots/test_button.ambr new file mode 100644 index 000000000000..a3512d61fc19 --- /dev/null +++ b/tests/components/my_pv/snapshots/test_button.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_button[button.my_pv_ac_elwa_2_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_pv_ac_elwa_2_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'my_pv', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1601500000000000-reboot_device', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.my_pv_ac_elwa_2_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'my-PV AC ELWA 2 Restart', + }), + 'context': , + 'entity_id': 'button.my_pv_ac_elwa_2_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/my_pv/test_button.py b/tests/components/my_pv/test_button.py new file mode 100644 index 000000000000..1c316b580d43 --- /dev/null +++ b/tests/components/my_pv/test_button.py @@ -0,0 +1,155 @@ +"""Test the my-PV button platform.""" + +from unittest.mock import AsyncMock, patch + +from my_pv.exceptions import MyPVAuthenticationError, MyPVConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_my_pv_client") +async def test_button( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test successful setup of a button platform.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_unavailable_not_connected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test if a button is unavailable when not connected.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + mock_my_pv_client.connected = False + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("button.my_pv_ac_elwa_2_restart") + assert state.state == STATE_UNAVAILABLE + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test successful press of a button.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + +async def test_button_press_send_command_returns_false( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test for HomeAssistantError when send_command returns False.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_my_pv_client.send_command.return_value = False + with ( + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + mock_my_pv_client.send_command.reset_mock() + mock_my_pv_client.send_command.return_value = True + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + +@pytest.mark.parametrize( + ("error", "expected_ha_error"), + [ + (MyPVConnectionError(), HomeAssistantError), + (MyPVAuthenticationError(), ConfigEntryAuthFailed), + ], +) +async def test_button_press_send_command_throws_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, + error: MyPVConnectionError | MyPVAuthenticationError, + expected_ha_error: type[HomeAssistantError], +) -> None: + """Test for HomeAssistantError when send_command throws error.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_my_pv_client.send_command.side_effect = error + with ( + pytest.raises(expected_ha_error), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + mock_my_pv_client.send_command.reset_mock() + mock_my_pv_client.send_command.side_effect = None + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None)