From fd0fee1900bb50b9ad2198694eef98aa55d5f46c Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Tue, 25 Jun 2024 08:09:54 +0200 Subject: [PATCH] Add button platform to pyLoad integration (#120359) --- homeassistant/components/pyload/__init__.py | 2 +- homeassistant/components/pyload/button.py | 107 ++++++++++ homeassistant/components/pyload/const.py | 3 + homeassistant/components/pyload/icons.json | 14 ++ homeassistant/components/pyload/strings.json | 14 ++ .../pyload/snapshots/test_button.ambr | 185 ++++++++++++++++++ tests/components/pyload/test_button.py | 83 ++++++++ tests/components/pyload/test_sensor.py | 14 +- 8 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/pyload/button.py create mode 100644 tests/components/pyload/snapshots/test_button.ambr create mode 100644 tests/components/pyload/test_button.py diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index d7c7e9454ea..b30b044e238 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] type PyLoadConfigEntry = ConfigEntry[PyLoadCoordinator] diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py new file mode 100644 index 00000000000..1f6bf3c3d10 --- /dev/null +++ b/homeassistant/components/pyload/button.py @@ -0,0 +1,107 @@ +"""Support for monitoring pyLoad.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pyloadapi.api import PyLoadAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PyLoadConfigEntry +from .const import DOMAIN, MANUFACTURER, SERVICE_NAME +from .coordinator import PyLoadCoordinator + + +@dataclass(kw_only=True, frozen=True) +class PyLoadButtonEntityDescription(ButtonEntityDescription): + """Describes pyLoad button entity.""" + + press_fn: Callable[[PyLoadAPI], Awaitable[Any]] + + +class PyLoadButtonEntity(StrEnum): + """PyLoad button Entities.""" + + ABORT_DOWNLOADS = "abort_downloads" + RESTART_FAILED = "restart_failed" + DELETE_FINISHED = "delete_finished" + RESTART = "restart" + + +SENSOR_DESCRIPTIONS: tuple[PyLoadButtonEntityDescription, ...] = ( + PyLoadButtonEntityDescription( + key=PyLoadButtonEntity.ABORT_DOWNLOADS, + translation_key=PyLoadButtonEntity.ABORT_DOWNLOADS, + press_fn=lambda api: api.stop_all_downloads(), + ), + PyLoadButtonEntityDescription( + key=PyLoadButtonEntity.RESTART_FAILED, + translation_key=PyLoadButtonEntity.RESTART_FAILED, + press_fn=lambda api: api.restart_failed(), + ), + PyLoadButtonEntityDescription( + key=PyLoadButtonEntity.DELETE_FINISHED, + translation_key=PyLoadButtonEntity.DELETE_FINISHED, + press_fn=lambda api: api.delete_finished(), + ), + PyLoadButtonEntityDescription( + key=PyLoadButtonEntity.RESTART, + translation_key=PyLoadButtonEntity.RESTART, + press_fn=lambda api: api.restart(), + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PyLoadConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons from a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + PyLoadBinarySensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PyLoadBinarySensor(CoordinatorEntity[PyLoadCoordinator], ButtonEntity): + """Representation of a pyLoad button.""" + + _attr_has_entity_name = True + entity_description: PyLoadButtonEntityDescription + + def __init__( + self, + coordinator: PyLoadCoordinator, + entity_description: PyLoadButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=SERVICE_NAME, + configuration_url=coordinator.pyload.api_url, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + translation_key=DOMAIN, + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self.coordinator.pyload) diff --git a/homeassistant/components/pyload/const.py b/homeassistant/components/pyload/const.py index 8ee1c05696f..9419786fd88 100644 --- a/homeassistant/components/pyload/const.py +++ b/homeassistant/components/pyload/const.py @@ -7,3 +7,6 @@ DEFAULT_NAME = "pyLoad" DEFAULT_PORT = 8000 ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=pyload"} + +MANUFACTURER = "pyLoad Team" +SERVICE_NAME = "pyLoad" diff --git a/homeassistant/components/pyload/icons.json b/homeassistant/components/pyload/icons.json index bc068165851..8f6f016641f 100644 --- a/homeassistant/components/pyload/icons.json +++ b/homeassistant/components/pyload/icons.json @@ -1,5 +1,19 @@ { "entity": { + "button": { + "abort_downloads": { + "default": "mdi:stop" + }, + "restart_failed": { + "default": "mdi:cached" + }, + "delete_finished": { + "default": "mdi:trash-can" + }, + "restart": { + "default": "mdi:restart" + } + }, "sensor": { "speed": { "default": "mdi:speedometer" diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index cc53ef7465b..94c0c29d286 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -28,6 +28,20 @@ } }, "entity": { + "button": { + "abort_downloads": { + "name": "Abort all running downloads" + }, + "restart_failed": { + "name": "Restart all failed files" + }, + "delete_finished": { + "name": "Delete finished files/packages" + }, + "restart": { + "name": "Restart pyload core" + } + }, "sensor": { "speed": { "name": "Speed" diff --git a/tests/components/pyload/snapshots/test_button.ambr b/tests/components/pyload/snapshots/test_button.ambr new file mode 100644 index 00000000000..c9a901aba15 --- /dev/null +++ b/tests/components/pyload/snapshots/test_button.ambr @@ -0,0 +1,185 @@ +# serializer version: 1 +# name: test_state[button.pyload_abort_all_running_downloads-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.pyload_abort_all_running_downloads', + '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': 'Abort all running downloads', + 'platform': 'pyload', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'XXXXXXXXXXXXXX_abort_downloads', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[button.pyload_abort_all_running_downloads-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'pyload Abort all running downloads', + }), + 'context': , + 'entity_id': 'button.pyload_abort_all_running_downloads', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_state[button.pyload_delete_finished_files_packages-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.pyload_delete_finished_files_packages', + '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': 'Delete finished files/packages', + 'platform': 'pyload', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'XXXXXXXXXXXXXX_delete_finished', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[button.pyload_delete_finished_files_packages-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'pyload Delete finished files/packages', + }), + 'context': , + 'entity_id': 'button.pyload_delete_finished_files_packages', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_state[button.pyload_restart_all_failed_files-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.pyload_restart_all_failed_files', + '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': 'Restart all failed files', + 'platform': 'pyload', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'XXXXXXXXXXXXXX_restart_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[button.pyload_restart_all_failed_files-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'pyload Restart all failed files', + }), + 'context': , + 'entity_id': 'button.pyload_restart_all_failed_files', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_state[button.pyload_restart_pyload_core-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.pyload_restart_pyload_core', + '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': 'Restart pyload core', + 'platform': 'pyload', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'XXXXXXXXXXXXXX_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[button.pyload_restart_pyload_core-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'pyload Restart pyload core', + }), + 'context': , + 'entity_id': 'button.pyload_restart_pyload_core', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/pyload/test_button.py b/tests/components/pyload/test_button.py new file mode 100644 index 00000000000..b30a4cefd42 --- /dev/null +++ b/tests/components/pyload/test_button.py @@ -0,0 +1,83 @@ +"""The tests for the button component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.pyload.button import PyLoadButtonEntity +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +API_CALL = { + PyLoadButtonEntity.ABORT_DOWNLOADS: call.stop_all_downloads, + PyLoadButtonEntity.RESTART_FAILED: call.restart_failed, + PyLoadButtonEntity.DELETE_FINISHED: call.delete_finished, + PyLoadButtonEntity.RESTART: call.restart, +} + + +@pytest.fixture(autouse=True) +async def button_only() -> AsyncGenerator[None, None]: + """Enable only the button platform.""" + with patch( + "homeassistant.components.pyload.PLATFORMS", + [Platform.BUTTON], + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_pyloadapi: AsyncMock, +) -> None: + """Test button state.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_press( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pyloadapi: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch turn on method.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for entity_entry in entity_entries: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_entry.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert API_CALL[entity_entry.translation_key] in mock_pyloadapi.method_calls + mock_pyloadapi.reset_mock() diff --git a/tests/components/pyload/test_sensor.py b/tests/components/pyload/test_sensor.py index 49795284fc6..61a9a872f33 100644 --- a/tests/components/pyload/test_sensor.py +++ b/tests/components/pyload/test_sensor.py @@ -1,6 +1,7 @@ """Tests for the pyLoad Sensors.""" -from unittest.mock import AsyncMock +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError @@ -11,6 +12,7 @@ from homeassistant.components.pyload.const import DOMAIN from homeassistant.components.pyload.coordinator import SCAN_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.typing import ConfigType @@ -19,6 +21,16 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.fixture(autouse=True) +async def sensor_only() -> AsyncGenerator[None, None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.pyload.PLATFORMS", + [Platform.SENSOR], + ): + yield + + async def test_setup( hass: HomeAssistant, config_entry: MockConfigEntry,