From 06db5a55f87907c8434d4da6c2317e33b1cb1599 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 19:59:10 +0100 Subject: [PATCH] Add number platform to sabnzbd and deprecate custom action (#131029) * Add number platform to sabnzbd * Copy & waste error * Move to icon translations * Update snapshot --- homeassistant/components/sabnzbd/__init__.py | 11 +- homeassistant/components/sabnzbd/icons.json | 3 + homeassistant/components/sabnzbd/number.py | 82 ++++++++++++ homeassistant/components/sabnzbd/strings.json | 9 ++ .../sabnzbd/snapshots/test_number.ambr | 57 ++++++++ tests/components/sabnzbd/test_init.py | 2 + tests/components/sabnzbd/test_number.py | 123 ++++++++++++++++++ 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sabnzbd/number.py create mode 100644 tests/components/sabnzbd/snapshots/test_number.ambr create mode 100644 tests/components/sabnzbd/test_number.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index ade281a5de1..abc9b1b1356 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -27,7 +27,7 @@ from .const import ( from .coordinator import SabnzbdUpdateCoordinator from .sab import get_client -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( @@ -128,6 +128,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_set_queue_speed( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "set_speed_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="set_speed_action_deprecated", + ) speed = call.data.get(ATTR_SPEED) await coordinator.sab_api.set_speed_limit(speed) diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index 78eff1f4183..190aefe4b12 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -6,6 +6,9 @@ }, "resume": { "default": "mdi:play" + }, + "speedlimit": { + "default": "mdi:speedometer" } } }, diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py new file mode 100644 index 00000000000..31faca3f78b --- /dev/null +++ b/homeassistant/components/sabnzbd/number.py @@ -0,0 +1,82 @@ +"""Number entities for the SABnzbd integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pysabnzbd import SabnzbdApiException + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SabnzbdUpdateCoordinator +from .entity import SabnzbdEntity + + +@dataclass(frozen=True, kw_only=True) +class SabnzbdNumberEntityDescription(NumberEntityDescription): + """Class describing a SABnzbd number entities.""" + + set_fn: Callable[[SabnzbdUpdateCoordinator, float], Awaitable] + + +NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = ( + SabnzbdNumberEntityDescription( + key="speedlimit", + translation_key="speedlimit", + mode=NumberMode.BOX, + native_max_value=100, + native_min_value=0, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + set_fn=lambda coordinator, speed: ( + coordinator.sab_api.set_speed_limit(int(speed)) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SABnzbd number entity.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + SabnzbdNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS + ) + + +class SabnzbdNumber(SabnzbdEntity, NumberEntity): + """Representation of a SABnzbd number.""" + + entity_description: SabnzbdNumberEntityDescription + + @property + def native_value(self) -> float: + """Return latest value for number.""" + return self.coordinator.data[self.entity_description.key] + + async def async_set_native_value(self, value: float) -> None: + """Set the new number value.""" + try: + await self.entity_description.set_fn(self.coordinator, value) + 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/strings.json b/homeassistant/components/sabnzbd/strings.json index 3162aab60ec..5d573c6a8cb 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -26,6 +26,11 @@ "name": "[%key:component::sabnzbd::services::resume::name%]" } }, + "number": { + "speedlimit": { + "name": "Speedlimit" + } + }, "sensor": { "status": { "name": "Status" @@ -106,6 +111,10 @@ "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." + }, + "set_speed_action_deprecated": { + "title": "SABnzbd set_speed action deprecated", + "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." } }, "exceptions": { diff --git a/tests/components/sabnzbd/snapshots/test_number.ambr b/tests/components/sabnzbd/snapshots/test_number.ambr new file mode 100644 index 00000000000..6a370797264 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_number_setup[number.sabnzbd_speedlimit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.sabnzbd_speedlimit', + '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': 'Speedlimit', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speedlimit', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_speedlimit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_setup[number.sabnzbd_speedlimit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Speedlimit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.sabnzbd_speedlimit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index 1c7415cecca..9b833875bbc 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -7,6 +7,7 @@ from homeassistant.components.sabnzbd.const import ( DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, + SERVICE_SET_SPEED, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -17,6 +18,7 @@ from homeassistant.helpers import issue_registry as ir [ (SERVICE_RESUME, "resume_action_deprecated"), (SERVICE_PAUSE, "pause_action_deprecated"), + (SERVICE_SET_SPEED, "set_speed_action_deprecated"), ], ) @pytest.mark.usefixtures("setup_integration") diff --git a/tests/components/sabnzbd/test_number.py b/tests/components/sabnzbd/test_number.py new file mode 100644 index 00000000000..61f7ea45ab1 --- /dev/null +++ b/tests/components/sabnzbd/test_number.py @@ -0,0 +1,123 @@ +"""Number 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.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, 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.NUMBER]) +async def test_number_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test number 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( + ("number", "input_number", "called_function", "expected_state"), + [ + ("speedlimit", 50.0, "set_speed_limit", 50), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_set( + hass: HomeAssistant, + sabnzbd: AsyncMock, + number: str, + input_number: float, + called_function: str, + expected_state: str, +) -> None: + """Test the sabnzbd number set.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_VALUE: input_number, + ATTR_ENTITY_ID: f"number.sabnzbd_{number}", + }, + blocking=True, + ) + + function = getattr(sabnzbd, called_function) + function.assert_called_with(int(input_number)) + + +@pytest.mark.parametrize( + ("number", "input_number", "called_function"), + [("speedlimit", 55.0, "set_speed_limit")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_exception( + hass: HomeAssistant, + sabnzbd: AsyncMock, + number: str, + input_number: float, + called_function: str, +) -> None: + """Test the number entity 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( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_VALUE: input_number, + ATTR_ENTITY_ID: f"number.sabnzbd_{number}", + }, + blocking=True, + ) + + function.assert_called_once() + + +@pytest.mark.parametrize( + ("number", "initial_state"), + [("speedlimit", "85")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + sabnzbd: AsyncMock, + number: str, + initial_state: str, +) -> None: + """Test the number is unavailable when coordinator can't update data.""" + state = hass.states.get(f"number.sabnzbd_{number}") + assert state + assert state.state == initial_state + + 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"number.sabnzbd_{number}") + assert state + assert state.state == STATE_UNAVAILABLE