diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 62bb1acc5e1..4d1494d9d17 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: diff --git a/homeassistant/components/casper_glow/button.py b/homeassistant/components/casper_glow/button.py new file mode 100644 index 00000000000..225b5ab5416 --- /dev/null +++ b/homeassistant/components/casper_glow/button.py @@ -0,0 +1,73 @@ +"""Casper Glow integration button platform.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pycasperglow import CasperGlow + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class CasperGlowButtonEntityDescription(ButtonEntityDescription): + """Describe a Casper Glow button entity.""" + + press_fn: Callable[[CasperGlow], Awaitable[None]] + + +BUTTON_DESCRIPTIONS: tuple[CasperGlowButtonEntityDescription, ...] = ( + CasperGlowButtonEntityDescription( + key="pause", + translation_key="pause", + press_fn=lambda device: device.pause(), + ), + CasperGlowButtonEntityDescription( + key="resume", + translation_key="resume", + press_fn=lambda device: device.resume(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the button platform for Casper Glow.""" + async_add_entities( + CasperGlowButton(entry.runtime_data, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class CasperGlowButton(CasperGlowEntity, ButtonEntity): + """A Casper Glow button entity.""" + + entity_description: CasperGlowButtonEntityDescription + + def __init__( + self, + coordinator: CasperGlowCoordinator, + description: CasperGlowButtonEntityDescription, + ) -> None: + """Initialize a Casper Glow button.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{format_mac(coordinator.device.address)}_{description.key}" + ) + + async def async_press(self) -> None: + """Press the button.""" + await self._async_command(self.entity_description.press_fn(self._device)) diff --git a/homeassistant/components/casper_glow/icons.json b/homeassistant/components/casper_glow/icons.json index aff2cfe99a3..c291e1abc22 100644 --- a/homeassistant/components/casper_glow/icons.json +++ b/homeassistant/components/casper_glow/icons.json @@ -4,6 +4,14 @@ "paused": { "default": "mdi:timer-pause" } + }, + "button": { + "pause": { + "default": "mdi:pause" + }, + "resume": { + "default": "mdi:play" + } } } } diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json index b883e7372e2..83b2a3a2f43 100644 --- a/homeassistant/components/casper_glow/manifest.json +++ b/homeassistant/components/casper_glow/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pycasperglow"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pycasperglow==1.1.0"] } diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index e6ec68c6764..7f73eb17602 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -32,7 +32,9 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: + status: exempt + comment: Bluetooth device with no authentication credentials. test-coverage: done # Gold @@ -53,15 +55,9 @@ rules: entity-category: todo entity-device-class: todo entity-disabled-by-default: todo - entity-translations: - status: exempt - comment: No entity translations needed. - exception-translations: - status: exempt - comment: No custom services that raise exceptions. - icon-translations: - status: exempt - comment: No icon translations needed. + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: todo repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json index 74043d3ca3a..a9d70090170 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -31,6 +31,14 @@ "paused": { "name": "Dimming paused" } + }, + "button": { + "pause": { + "name": "Pause dimming" + }, + "resume": { + "name": "Resume dimming" + } } }, "exceptions": { diff --git a/tests/components/casper_glow/snapshots/test_button.ambr b/tests/components/casper_glow/snapshots/test_button.ambr new file mode 100644 index 00000000000..2ed565370bb --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_button.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_entities[button.jar_pause_dimming-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': None, + 'entity_id': 'button.jar_pause_dimming', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pause dimming', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pause dimming', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'aa:bb:cc:dd:ee:ff_pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.jar_pause_dimming-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jar Pause dimming', + }), + 'context': , + 'entity_id': 'button.jar_pause_dimming', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[button.jar_resume_dimming-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': None, + 'entity_id': 'button.jar_resume_dimming', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Resume dimming', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resume dimming', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': 'aa:bb:cc:dd:ee:ff_resume', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.jar_resume_dimming-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jar Resume dimming', + }), + 'context': , + 'entity_id': 'button.jar_resume_dimming', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_button.py b/tests/components/casper_glow/test_button.py new file mode 100644 index 00000000000..0f5b23bc308 --- /dev/null +++ b/tests/components/casper_glow/test_button.py @@ -0,0 +1,85 @@ +"""Test the Casper Glow button platform.""" + +from unittest.mock import MagicMock, patch + +from pycasperglow import CasperGlowError +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 . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +PAUSE_ENTITY_ID = "button.jar_pause_dimming" +RESUME_ENTITY_ID = "button.jar_resume_dimming" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all button entities match the snapshot.""" + with patch("homeassistant.components.casper_glow.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( + ("entity_id", "method"), + [ + (PAUSE_ENTITY_ID, "pause"), + (RESUME_ENTITY_ID, "resume"), + ], +) +async def test_button_press_calls_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test that pressing a button calls the correct device method.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + getattr(mock_casper_glow, method).assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + (PAUSE_ENTITY_ID, "pause"), + (RESUME_ENTITY_ID, "resume"), + ], +) +async def test_button_raises_homeassistant_error_on_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test that a CasperGlowError is converted to HomeAssistantError.""" + getattr(mock_casper_glow, method).side_effect = CasperGlowError("connection lost") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )