diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index 9289ccdbfb3..9cfb819b90d 100644 --- a/homeassistant/components/airobot/__init__.py +++ b/homeassistant/components/airobot/__init__.py @@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool: diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py new file mode 100644 index 00000000000..fba02b6fe1e --- /dev/null +++ b/homeassistant/components/airobot/button.py @@ -0,0 +1,89 @@ +"""Button platform for Airobot integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pyairobotrest.exceptions import ( + AirobotConnectionError, + AirobotError, + AirobotTimeoutError, +) + +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 .const import DOMAIN +from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator +from .entity import AirobotEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirobotButtonEntityDescription(ButtonEntityDescription): + """Describes Airobot button entity.""" + + press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] + + +BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( + AirobotButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_fn=lambda coordinator: coordinator.client.reboot_thermostat(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Airobot button entities.""" + coordinator = entry.runtime_data + + async_add_entities( + AirobotButton(coordinator, description) for description in BUTTON_TYPES + ) + + +class AirobotButton(AirobotEntity, ButtonEntity): + """Representation of an Airobot button.""" + + entity_description: AirobotButtonEntityDescription + + def __init__( + self, + coordinator: AirobotDataUpdateCoordinator, + description: AirobotButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn(self.coordinator) + except (AirobotConnectionError, AirobotTimeoutError): + # Connection errors during reboot are expected as device restarts + pass + except AirobotError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="button_press_failed", + translation_placeholders={"button": self.entity_description.key}, + ) from err diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index 77b264d1c44..ac9f26b2e4c 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -86,6 +86,9 @@ "authentication_failed": { "message": "Authentication failed, please reauthenticate." }, + "button_press_failed": { + "message": "Failed to press {button} button." + }, "connection_failed": { "message": "Failed to communicate with device." }, diff --git a/tests/components/airobot/snapshots/test_button.ambr b/tests/components/airobot/snapshots/test_button.ambr new file mode 100644 index 00000000000..d378d5c6e24 --- /dev/null +++ b/tests/components/airobot/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_buttons[button.test_thermostat_restart-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.test_thermostat_restart', + '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', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'T01A1B2C3_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_thermostat_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test Thermostat Restart', + }), + 'context': , + 'entity_id': 'button.test_thermostat_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py new file mode 100644 index 00000000000..60ddb2e8e96 --- /dev/null +++ b/tests/components/airobot/test_button.py @@ -0,0 +1,95 @@ +"""Tests for the Airobot button platform.""" + +from unittest.mock import AsyncMock + +from pyairobotrest.exceptions import ( + AirobotConnectionError, + AirobotError, + AirobotTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, 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 tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +@pytest.mark.usefixtures("init_integration") +async def test_buttons( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the button entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_restart_button( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test restart button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, + blocking=True, + ) + + mock_airobot_client.reboot_thermostat.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_restart_button_error( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test restart button error handling for unexpected errors.""" + mock_airobot_client.reboot_thermostat.side_effect = AirobotError("Test error") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, + blocking=True, + ) + + mock_airobot_client.reboot_thermostat.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "exception", + [AirobotConnectionError("Connection lost"), AirobotTimeoutError("Timeout")], +) +async def test_restart_button_connection_errors( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, + exception: Exception, +) -> None: + """Test restart button handles connection/timeout errors gracefully.""" + mock_airobot_client.reboot_thermostat.side_effect = exception + + # Should not raise an error - connection errors during reboot are expected + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, + blocking=True, + ) + + mock_airobot_client.reboot_thermostat.assert_called_once()