From 2cfacd8bc540185d2baacdd9eb8a494223605003 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 09:30:06 +0100 Subject: [PATCH] Add button platform to sabnzbd and deprecate custom actions (#130999) --- homeassistant/components/sabnzbd/__init__.py | 21 +++- homeassistant/components/sabnzbd/button.py | 69 +++++++++++ homeassistant/components/sabnzbd/icons.json | 10 ++ homeassistant/components/sabnzbd/strings.json | 23 ++++ tests/components/sabnzbd/conftest.py | 9 +- .../sabnzbd/snapshots/test_button.ambr | 93 ++++++++++++++ tests/components/sabnzbd/test_button.py | 116 ++++++++++++++++++ tests/components/sabnzbd/test_init.py | 45 ++++++- tests/components/sabnzbd/test_sensor.py | 7 +- 9 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/sabnzbd/button.py create mode 100644 tests/components/sabnzbd/snapshots/test_button.ambr create mode 100644 tests/components/sabnzbd/test_button.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 3182e628a6b..92b596ae288 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( @@ -41,7 +42,7 @@ from .coordinator import SabnzbdUpdateCoordinator from .sab import get_client from .sensor import OLD_SENSOR_KEYS -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( @@ -204,12 +205,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_pause_queue( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "pause_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="pause_action_deprecated", + ) await coordinator.sab_api.pause_queue() @extract_api async def async_resume_queue( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "resume_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="resume_action_deprecated", + ) await coordinator.sab_api.resume_queue() @extract_api diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py new file mode 100644 index 00000000000..4efecba18a7 --- /dev/null +++ b/homeassistant/components/sabnzbd/button.py @@ -0,0 +1,69 @@ +"""Button platform for the SABnzbd component.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pysabnzbd import SabnzbdApiException + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SabnzbdUpdateCoordinator +from .const import DOMAIN +from .entity import SabnzbdEntity + + +@dataclass(kw_only=True, frozen=True) +class SabnzbdButtonEntityDescription(ButtonEntityDescription): + """Describes SABnzbd button entity.""" + + press_fn: Callable[[SabnzbdUpdateCoordinator], Any] + + +BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = ( + SabnzbdButtonEntityDescription( + key="pause", + translation_key="pause", + press_fn=lambda coordinator: coordinator.sab_api.pause_queue(), + ), + SabnzbdButtonEntityDescription( + key="resume", + translation_key="resume", + press_fn=lambda coordinator: coordinator.sab_api.resume_queue(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + SabnzbdButton(coordinator, description) for description in BUTTON_DESCRIPTIONS + ) + + +class SabnzbdButton(SabnzbdEntity, ButtonEntity): + """Representation of a SABnzbd button.""" + + entity_description: SabnzbdButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn(self.coordinator) + except SabnzbdApiException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index ca4f4d584ae..78eff1f4183 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "button": { + "pause": { + "default": "mdi:pause" + }, + "resume": { + "default": "mdi:play" + } + } + }, "services": { "pause": { "service": "mdi:pause" diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 5b7312e3b0d..3162aab60ec 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -18,6 +18,14 @@ } }, "entity": { + "button": { + "pause": { + "name": "[%key:common::action::pause%]" + }, + "resume": { + "name": "[%key:component::sabnzbd::services::resume::name%]" + } + }, "sensor": { "status": { "name": "Status" @@ -89,5 +97,20 @@ } } } + }, + "issues": { + "pause_action_deprecated": { + "title": "SABnzbd pause action deprecated", + "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." + }, + "resume_action_deprecated": { + "title": "SABnzbd resume action deprecated", + "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." + } + }, + "exceptions": { + "service_call_exception": { + "message": "Unable to send command to SABnzbd due to a connection error, try again later" + } } } diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 68b17bbfeac..fc01429378b 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -35,9 +35,9 @@ def mock_sabnzbd() -> Generator[AsyncMock]: @pytest.fixture(name="config_entry") -async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +async def mock_config_entry(hass: HomeAssistant, sabnzbd: AsyncMock) -> MockConfigEntry: """Return a MockConfigEntry for testing.""" - return MockConfigEntry( + config_entry = MockConfigEntry( domain=DOMAIN, title="Sabnzbd", entry_id="01JD2YVVPBC62D620DGYNG2R8H", @@ -47,6 +47,9 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_URL: "http://localhost:8080", }, ) + config_entry.add_to_hass(hass) + + return config_entry @pytest.fixture(name="setup_integration") @@ -54,7 +57,5 @@ async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, sabnzbd: AsyncMock ) -> None: """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sabnzbd/snapshots/test_button.ambr b/tests/components/sabnzbd/snapshots/test_button.ambr new file mode 100644 index 00000000000..9b965e10518 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_button.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_button_setup[button.sabnzbd_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.sabnzbd_pause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pause', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.sabnzbd_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Pause', + }), + 'context': , + 'entity_id': 'button.sabnzbd_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.sabnzbd_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.sabnzbd_resume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resume', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_resume', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.sabnzbd_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Resume', + }), + 'context': , + 'entity_id': 'button.sabnzbd_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sabnzbd/test_button.py b/tests/components/sabnzbd/test_button.py new file mode 100644 index 00000000000..199d8eb03a0 --- /dev/null +++ b/tests/components/sabnzbd/test_button.py @@ -0,0 +1,116 @@ +"""Button tests for the SABnzbd component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pysabnzbd import SabnzbdApiException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.BUTTON]) +async def test_button_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("button", "called_function"), + [("resume", "resume_queue"), ("pause", "pause_queue")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_button_presses( + hass: HomeAssistant, + sabnzbd: AsyncMock, + button: str, + called_function: str, +) -> None: + """Test the sabnzbd button presses.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.sabnzbd_{button}", + }, + blocking=True, + ) + + function = getattr(sabnzbd, called_function) + function.assert_called_once() + + +@pytest.mark.parametrize( + ("button", "called_function"), + [("resume", "resume_queue"), ("pause", "pause_queue")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_buttons_exception( + hass: HomeAssistant, + sabnzbd: AsyncMock, + button: str, + called_function: str, +) -> None: + """Test the button handles errors.""" + function = getattr(sabnzbd, called_function) + function.side_effect = SabnzbdApiException("Boom") + + with pytest.raises( + HomeAssistantError, + match="Unable to send command to SABnzbd due to a connection error, try again later", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.sabnzbd_{button}", + }, + blocking=True, + ) + + function.assert_called_once() + + +@pytest.mark.parametrize( + "button", + ["resume", "pause"], +) +@pytest.mark.usefixtures("setup_integration") +async def test_buttons_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + sabnzbd: AsyncMock, + button: str, +) -> None: + """Test the button is unavailable when coordinator can't update data.""" + state = hass.states.get(f"button.sabnzbd_{button}") + assert state + assert state.state == STATE_UNKNOWN + + sabnzbd.refresh_data.side_effect = Exception("Boom") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(f"button.sabnzbd_{button}") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index e666f9f1d3e..8c771918259 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -2,11 +2,24 @@ from unittest.mock import patch -from homeassistant.components.sabnzbd import DEFAULT_NAME, DOMAIN, OLD_SENSOR_KEYS +import pytest + +from homeassistant.components.sabnzbd import ( + ATTR_API_KEY, + DEFAULT_NAME, + DOMAIN, + OLD_SENSOR_KEYS, + SERVICE_PAUSE, + SERVICE_RESUME, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from tests.common import MockConfigEntry @@ -75,3 +88,31 @@ async def test_unique_id_migrate( assert device_registry.async_get(mock_d_entry.id).identifiers == { (DOMAIN, MOCK_ENTRY_ID) } + + +@pytest.mark.parametrize( + ("service", "issue_id"), + [ + (SERVICE_RESUME, "resume_action_deprecated"), + (SERVICE_PAUSE, "pause_action_deprecated"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_deprecated_service_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + service: str, + issue_id: str, +) -> None: + """Test that deprecated actions creates an issue.""" + await hass.services.async_call( + DOMAIN, + service, + {ATTR_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0"}, + blocking=True, + ) + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + assert issue + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.breaks_in_ha_version == "2025.6" diff --git a/tests/components/sabnzbd/test_sensor.py b/tests/components/sabnzbd/test_sensor.py index aade644072c..31c0868a5a7 100644 --- a/tests/components/sabnzbd/test_sensor.py +++ b/tests/components/sabnzbd/test_sensor.py @@ -1,15 +1,19 @@ """Sensor tests for the Sabnzbd component.""" +from unittest.mock import patch + import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -17,4 +21,5 @@ async def test_sensor( snapshot: SnapshotAssertion, ) -> None: """Test sensor setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)