Add button platform to sabnzbd and deprecate custom actions (#130999)

This commit is contained in:
Jan-Philipp Benecke 2024-11-20 09:30:06 +01:00 committed by GitHub
parent 85610901e0
commit 2cfacd8bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 385 additions and 8 deletions

View File

@ -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

View File

@ -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()

View File

@ -1,4 +1,14 @@
{
"entity": {
"button": {
"pause": {
"default": "mdi:pause"
},
"resume": {
"default": "mdi:play"
}
}
},
"services": {
"pause": {
"service": "mdi:pause"

View File

@ -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"
}
}
}

View File

@ -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()

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.sabnzbd_pause',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.sabnzbd_pause',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button_setup[button.sabnzbd_resume-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.sabnzbd_resume',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.sabnzbd_resume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -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

View File

@ -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"

View File

@ -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)