From ee7262efb4950f8fd4257035d71350b8964a4891 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 5 Oct 2025 10:36:52 +0200 Subject: [PATCH] Portainer add button platform (#153063) --- .../components/portainer/__init__.py | 2 +- homeassistant/components/portainer/button.py | 128 +++++++++ .../components/portainer/quality_scale.yaml | 5 +- tests/components/portainer/conftest.py | 3 +- .../portainer/snapshots/test_button.ambr | 246 ++++++++++++++++++ tests/components/portainer/test_button.py | 114 ++++++++ 6 files changed, 491 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/portainer/button.py create mode 100644 tests/components/portainer/snapshots/test_button.ambr create mode 100644 tests/components/portainer/test_button.py diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 732831b27c5e..ba78ee324091 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PortainerCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH] type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py new file mode 100644 index 000000000000..917bc9f46765 --- /dev/null +++ b/homeassistant/components/portainer/button.py @@ -0,0 +1,128 @@ +"""Support for Portainer buttons.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from pyportainer import Portainer +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +from pyportainer.models.docker import DockerContainer + +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 PortainerConfigEntry +from .const import DOMAIN +from .coordinator import PortainerCoordinator, PortainerCoordinatorData +from .entity import PortainerContainerEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class PortainerButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer button entity.""" + + press_action: Callable[ + [Portainer, int, str], + Coroutine[Any, Any, None], + ] + + +BUTTONS: tuple[PortainerButtonDescription, ...] = ( + PortainerButtonDescription( + key="restart", + name="Restart Container", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.restart_container( + endpoint_id, container_id + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer buttons.""" + coordinator: PortainerCoordinator = entry.runtime_data + + async_add_entities( + PortainerButton( + coordinator=coordinator, + entity_description=entity_description, + device_info=container, + via_device=endpoint, + ) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + for entity_description in BUTTONS + ) + + +class PortainerButton(PortainerContainerEntity, ButtonEntity): + """Defines a Portainer button.""" + + entity_description: PortainerButtonDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerButtonDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer button entity.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + device_identifier = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" + + async def async_press(self) -> None: + """Trigger the Portainer button press service.""" + try: + await self.entity_description.press_action( + self.coordinator.portainer, self.endpoint_id, self.device_id + ) + except PortainerConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml index fd13fd350658..d26f0087d87c 100644 --- a/homeassistant/components/portainer/quality_scale.yaml +++ b/homeassistant/components/portainer/quality_scale.yaml @@ -26,10 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - No custom actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 90a3fe65b158..446572083fa3 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -49,8 +49,7 @@ def mock_portainer_client() -> Generator[AsyncMock]: DockerContainer.from_dict(container) for container in load_json_array_fixture("containers.json", DOMAIN) ] - client.start_container = AsyncMock(return_value=None) - client.stop_container = AsyncMock(return_value=None) + client.restart_container = AsyncMock(return_value=None) yield client diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr new file mode 100644 index 000000000000..83d4f65aaf24 --- /dev/null +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.focused_einstein_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_focused_einstein_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'focused_einstein Restart Container', + }), + 'context': , + 'entity_id': 'button.focused_einstein_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.funny_chatelet_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_funny_chatelet_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'funny_chatelet Restart Container', + }), + 'context': , + 'entity_id': 'button.funny_chatelet_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.practical_morse_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_practical_morse_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'practical_morse Restart Container', + }), + 'context': , + 'entity_id': 'button.practical_morse_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.serene_banach_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_serene_banach_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'serene_banach Restart Container', + }), + 'context': , + 'entity_id': 'button.serene_banach_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.stoic_turing_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_stoic_turing_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'stoic_turing Restart Container', + }), + 'context': , + 'entity_id': 'button.stoic_turing_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_button.py b/tests/components/portainer/test_button.py new file mode 100644 index 000000000000..8f99e2faa208 --- /dev/null +++ b/tests/components/portainer/test_button.py @@ -0,0 +1,114 @@ +"""Tests for the Portainer button platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, 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, snapshot_platform + +BUTTON_DOMAIN = "button" + + +async def test_all_button_entities_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer button entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("action", "client_method"), + [ + ("restart", "restart_container"), + ], +) +async def test_buttons( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + client_method: str, +) -> None: + """Test pressing a Portainer container action button triggers client call. Click, click!""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = f"button.practical_morse_{action}_container" + method_mock = getattr(mock_portainer_client, client_method) + pre_calls = len(method_mock.mock_calls) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(method_mock.mock_calls) == pre_calls + 1 + + +@pytest.mark.parametrize( + ("exception", "client_method"), + [ + (PortainerAuthenticationError("auth"), "restart_container"), + (PortainerConnectionError("conn"), "restart_container"), + (PortainerTimeoutError("timeout"), "restart_container"), + ], +) +async def test_buttons_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + client_method: str, +) -> None: + """Test that Portainer buttons, but this time when they will do boom for sure.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + action = client_method.split("_")[0] + entity_id = f"button.practical_morse_{action}_container" + + method_mock = getattr(mock_portainer_client, client_method) + method_mock.side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )