From 8558cab58e86abba14908216c1d62f15225cbac7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:47:45 +0000 Subject: [PATCH 01/16] Add Pterodactyl integration --- CODEOWNERS | 2 + .../components/pterodactyl/__init__.py | 27 ++++ homeassistant/components/pterodactyl/api.py | 141 ++++++++++++++++++ .../components/pterodactyl/binary_sensor.py | 81 ++++++++++ .../components/pterodactyl/config_flow.py | 55 +++++++ homeassistant/components/pterodactyl/const.py | 3 + .../components/pterodactyl/coordinator.py | 67 +++++++++ .../components/pterodactyl/entity.py | 42 ++++++ .../components/pterodactyl/icons.json | 24 +++ .../components/pterodactyl/manifest.json | 10 ++ .../components/pterodactyl/quality_scale.yaml | 93 ++++++++++++ .../components/pterodactyl/strings.json | 50 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/pterodactyl/__init__.py | 1 + tests/components/pterodactyl/conftest.py | 136 +++++++++++++++++ .../pterodactyl/test_config_flow.py | 119 +++++++++++++++ 19 files changed, 864 insertions(+) create mode 100644 homeassistant/components/pterodactyl/__init__.py create mode 100644 homeassistant/components/pterodactyl/api.py create mode 100644 homeassistant/components/pterodactyl/binary_sensor.py create mode 100644 homeassistant/components/pterodactyl/config_flow.py create mode 100644 homeassistant/components/pterodactyl/const.py create mode 100644 homeassistant/components/pterodactyl/coordinator.py create mode 100644 homeassistant/components/pterodactyl/entity.py create mode 100644 homeassistant/components/pterodactyl/icons.json create mode 100644 homeassistant/components/pterodactyl/manifest.json create mode 100644 homeassistant/components/pterodactyl/quality_scale.yaml create mode 100644 homeassistant/components/pterodactyl/strings.json create mode 100644 tests/components/pterodactyl/__init__.py create mode 100644 tests/components/pterodactyl/conftest.py create mode 100644 tests/components/pterodactyl/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 87f170009f08e7..83d6efa1918f43 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1183,6 +1183,8 @@ build.json @home-assistant/supervisor /tests/components/prusalink/ @balloob /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/pterodactyl/ @elmurato +/tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas /tests/components/pure_energie/ @klaasnicolaas /homeassistant/components/purpleair/ @bachya diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py new file mode 100644 index 00000000000000..33b3cc7576fe5d --- /dev/null +++ b/homeassistant/components/pterodactyl/__init__.py @@ -0,0 +1,27 @@ +"""The Pterodactyl integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: + """Set up Pterodactyl from a config entry.""" + coordinator = PterodactylCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: PterodactylConfigEntry +) -> bool: + """Unload a Pterodactyl config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py new file mode 100644 index 00000000000000..b1e49620bfc934 --- /dev/null +++ b/homeassistant/components/pterodactyl/api.py @@ -0,0 +1,141 @@ +"""API module of the Pterodactyl integration.""" + +from dataclasses import dataclass +import logging + +from pydactyl import PterodactylClient +from pydactyl.exceptions import ( + BadRequestError, + ClientConfigError, + PterodactylApiError, + PydactylError, +) + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class PterodactylConfigurationError(Exception): + """Raised when the configuration is invalid.""" + + +class PterodactylConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +class PterodactylNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + +@dataclass +class PterodactylData: + """Data for the Pterodactyl server.""" + + name: str + uuid: str + identifier: str + state: str + memory_utilization: int + cpu_utilization: float + disk_utilization: int + network_rx_utilization: int + network_tx_utilization: int + uptime: int + + +class PterodactylAPI: + """Wrapper for Pterodactyl's API.""" + + pterodactyl: PterodactylClient | None + identifiers: list[str] + data_list: list[PterodactylData] + + def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: + """Initialize the Pterodactyl API.""" + self.hass = hass + self.host = host + self.api_key = api_key + self.pterodactyl = None + self.identifiers = [] + + async def async_init(self): + """Initialize the Pterodactyl API.""" + self.pterodactyl = PterodactylClient(self.host, self.api_key) + + try: + paginated_response = await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.list_servers + ) + except ClientConfigError as error: + raise PterodactylConfigurationError(error) from error + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error + except Exception as error: + _LOGGER.exception("Unexpected exception occurred during initialization") + raise PterodactylConnectionError(error) from error + else: + servers = paginated_response.collect() + for server in servers: + self.identifiers.append(server["attributes"]["identifier"]) + + _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) + + def get_index_from_identifier(self, identifier: str) -> int | None: + """Get the index of the data list from the identifier.""" + for index, data in enumerate(self.data_list): + if data.identifier == identifier: + return index + + return None + + async def async_get_data_list(self) -> list[PterodactylData]: + """Update the data from all Pterodactyl servers.""" + self.data_list = [] + current_identifier: str + + if self.pterodactyl is None: + raise PterodactylNotInitializedError( + "Pterodactyl API is not initialized yet" + ) + + try: + for identifier in self.identifiers: + current_identifier = identifier + server = await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.get_server, identifier + ) + utilization = await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.get_server_utilization, identifier + ) + + data = PterodactylData( + name=server["name"], + uuid=server["uuid"], + identifier=current_identifier, + state=utilization["current_state"], + cpu_utilization=utilization["resources"]["cpu_absolute"], + memory_utilization=utilization["resources"]["memory_bytes"], + disk_utilization=utilization["resources"]["disk_bytes"], + network_rx_utilization=utilization["resources"]["network_rx_bytes"], + network_tx_utilization=utilization["resources"]["network_tx_bytes"], + uptime=utilization["resources"]["uptime"], + ) + + self.data_list.append(data) + _LOGGER.debug("%s", data) + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error + except Exception as error: + _LOGGER.exception("Unexpected exception occurred during data update") + raise PterodactylConnectionError(error) from error + else: + return self.data_list diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py new file mode 100644 index 00000000000000..b21bd1ffd7274f --- /dev/null +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -0,0 +1,81 @@ +"""Binary sensor platform of the Pterodactyl integration.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_STATUS = "status" + + +BINARY_SENSOR_DESCRIPTIONS = [ + BinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.RUNNING, + ), +] + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl binary sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [ + PterodactylBinarySensorEntity( + coordinator, identifier, description, config_entry + ) + for identifier in coordinator.api.identifiers + for description in BINARY_SENSOR_DESCRIPTIONS + ] + ) + + +class PterodactylBinarySensorEntity(PterodactylEntity, BinarySensorEntity): + """Representation of a Pterodactyl binary sensor base entity.""" + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: BinarySensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self.identifier = identifier + self._attr_unique_id = f"{config_entry.entry_id}-{identifier}-{description.key}" + self._attr_is_on = False + + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return True + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + index = self.coordinator.api.get_index_from_identifier(self.identifier) + + if index is None: + raise HomeAssistantError( + f"Identifier '{self.identifier}' not found in data list" + ) + + return self.coordinator.data[index].state == "running" diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py new file mode 100644 index 00000000000000..d284fc0635de41 --- /dev/null +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -0,0 +1,55 @@ +"""Config flow for the Pterodactyl integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from .api import ( + PterodactylAPI, + PterodactylConfigurationError, + PterodactylConnectionError, +) +from .const import DOMAIN + +DEFAULT_HOST = "http://localhost:8080" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pterodactyl.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_HOST: host}) + api = PterodactylAPI(self.hass, host, api_key) + + try: + await api.async_init() + except (PterodactylConfigurationError, PterodactylConnectionError): + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=host, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/pterodactyl/const.py b/homeassistant/components/pterodactyl/const.py new file mode 100644 index 00000000000000..8cf4d0c39639d9 --- /dev/null +++ b/homeassistant/components/pterodactyl/const.py @@ -0,0 +1,3 @@ +"""Constants for the Pterodactyl integration.""" + +DOMAIN = "pterodactyl" diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py new file mode 100644 index 00000000000000..0543a0a3d9c21f --- /dev/null +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -0,0 +1,67 @@ +"""Data update coordinator of the Pterodactyl integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + PterodactylAPI, + PterodactylConfigurationError, + PterodactylConnectionError, + PterodactylData, + PterodactylNotInitializedError, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator] + + +class PterodactylCoordinator(DataUpdateCoordinator[list[PterodactylData]]): + """Pterodactyl data update coordinator.""" + + config_entry: PterodactylConfigEntry + api: PterodactylAPI + + def __init__( + self, + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize coordinator instance.""" + + super().__init__( + hass=hass, + name=config_entry.data[CONF_HOST], + config_entry=config_entry, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + """Set up the Pterodactyl data coordinator.""" + try: + self.api = PterodactylAPI( + hass=self.hass, + host=self.config_entry.data[CONF_HOST], + api_key=self.config_entry.data[CONF_API_KEY], + ) + await self.api.async_init() + except PterodactylConfigurationError as error: + raise ConfigEntryNotReady(error) from error + + async def _async_update_data(self) -> list[PterodactylData]: + """Get updated data from the Pterodactyl server.""" + try: + return await self.api.async_get_data_list() + except (PterodactylNotInitializedError, PterodactylConnectionError) as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py new file mode 100644 index 00000000000000..bda03e38500d72 --- /dev/null +++ b/homeassistant/components/pterodactyl/entity.py @@ -0,0 +1,42 @@ +"""Base entity for the Pterodactyl integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PterodactylCoordinator + +MANUFACTURER = "Pterodactyl" + + +class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]): + """Representation of a Pterodactyl base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + config_entry: ConfigEntry, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + + index = coordinator.api.get_index_from_identifier(identifier) + + if index is None: + raise HomeAssistantError( + f"Identifier '{identifier}' not found in data list" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + name=coordinator.data[index].name, + model=coordinator.data[index].name, + model_id=coordinator.data[index].uuid, + configuration_url=f"{config_entry.data['host']}/server/{identifier}", + ) diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json new file mode 100644 index 00000000000000..b0f6f2864c77f4 --- /dev/null +++ b/homeassistant/components/pterodactyl/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "cpu_usage": { + "default": "mdi:cpu-64-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "network_rx_usage": { + "default": "mdi:download" + }, + "network_tx_usage": { + "default": "mdi:upload" + }, + "uptime": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/pterodactyl/manifest.json b/homeassistant/components/pterodactyl/manifest.json new file mode 100644 index 00000000000000..8ffa21dd1866c0 --- /dev/null +++ b/homeassistant/components/pterodactyl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "pterodactyl", + "name": "Pterodactyl", + "codeowners": ["@elmurato"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pterodactyl", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["py-dactyl==2.0.4"] +} diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml new file mode 100644 index 00000000000000..dae3b9fa11afb3 --- /dev/null +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency pydactyl doesn't provide a unique information. + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if the initialization isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency pydactyl, + the server host is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: todo + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json new file mode 100644 index 00000000000000..691819411d2030 --- /dev/null +++ b/homeassistant/components/pterodactyl/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "host": "The hostname or IP address of your Pterodactyl server (including the port).", + "api_key": "The client API key for accessing your Pterodactyl server." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "cpu_usage": { + "name": "CPU Usage" + }, + "memory_usage": { + "name": "Memory Usage" + }, + "disk_usage": { + "name": "Disk Usage" + }, + "network_rx_usage": { + "name": "Network RX Usage" + }, + "network_tx_usage": { + "name": "Network TX Usage" + }, + "uptime": { + "name": "Uptime" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8284f77ef94fb6..56f888534edf04 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -488,6 +488,7 @@ "proximity", "prusalink", "ps4", + "pterodactyl", "pure_energie", "purpleair", "pushbullet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01ff9d14d905fc..38897da6309c8b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4963,6 +4963,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pterodactyl": { + "name": "Pterodactyl", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 55b4d1403211b6..3f0171deb17a6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1730,6 +1730,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 072250cad206e8..16b205d2a1bc4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1431,6 +1431,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 diff --git a/tests/components/pterodactyl/__init__.py b/tests/components/pterodactyl/__init__.py new file mode 100644 index 00000000000000..a5b28d67ae34b1 --- /dev/null +++ b/tests/components/pterodactyl/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pterodactyl integration.""" diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py new file mode 100644 index 00000000000000..444ca67bc54dba --- /dev/null +++ b/tests/components/pterodactyl/conftest.py @@ -0,0 +1,136 @@ +"""Common fixtures for the Pterodactyl tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from tests.common import MockConfigEntry + +TEST_HOST = "https://192.168.0.1:8080" +TEST_API_KEY = "TestClientApiKey" +TEST_USER_INPUT = { + CONF_HOST: TEST_HOST, + CONF_API_KEY: TEST_API_KEY, +} +TEST_SERVER_LIST_DATA = { + "meta": {"pagination": {"total": 2, "count": 2, "per_page": 50, "current_page": 1}}, + "data": [ + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "description": "Description of Test Server 1", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server1.jar", + "docker_image": "test_docker_image_1", + "egg_features": ["java_version"], + }, + }, + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "description": "Description of Test Server 2", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server_2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["java_version"], + }, + }, + ], +} +TEST_SERVER = { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "is_node_under_maintenance": False, + "sftp_details": {"ip": "192.168.0.1", "port": 2022}, + "description": "", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test.jar", + "docker_image": "test_docker_image", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": {"databases": 0, "allocations": 0, "backups": 3}, + "status": None, + "is_suspended": False, + "is_installing": False, + "is_transferring": False, + "relationships": {"allocations": {...}, "variables": {...}}, +} +TEST_SERVER_UTILIZATION = { + "current_state": "running", + "is_suspended": False, + "resources": { + "memory_bytes": 1111, + "cpu_absolute": 22, + "disk_bytes": 3333, + "network_rx_bytes": 44, + "network_tx_bytes": 55, + "uptime": 6666, + }, +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create Pterodactyl mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + title=TEST_HOST, + data={ + CONF_HOST: TEST_HOST, + CONF_API_KEY: TEST_API_KEY, + }, + version=1, + ) + + +@pytest.fixture +def mock_pterodactyl(): + """Mock the Pterodactyl API.""" + with patch( + "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True + ) as mock: + yield mock.return_value diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py new file mode 100644 index 00000000000000..4482bbd348e625 --- /dev/null +++ b/tests/components/pterodactyl/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Pterodactyl config flow.""" + +from pydactyl import PterodactylClient +from pydactyl.exceptions import ClientConfigError, PterodactylApiError +from pydactyl.responses import PaginatedResponse +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ( + TEST_HOST, + TEST_SERVER, + TEST_SERVER_LIST_DATA, + TEST_SERVER_UTILIZATION, + TEST_USER_INPUT, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_full_flow( + hass: HomeAssistant, mock_pterodactyl: PterodactylClient +) -> None: + """Test full flow without errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( + mock_pterodactyl, "client", TEST_SERVER_LIST_DATA + ) + mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER + mock_pterodactyl.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_HOST + assert result2["data"] == TEST_USER_INPUT + + +@pytest.mark.parametrize( + "exception_type", + [ + ClientConfigError, + PterodactylApiError, + ], +) +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_recovery_after_error( + hass: HomeAssistant, exception_type, mock_pterodactyl: PterodactylClient +) -> None: + """Test recovery after connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + mock_pterodactyl.reset_mock(side_effect=True) + mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( + mock_pterodactyl, "client", TEST_SERVER_LIST_DATA + ) + mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER + mock_pterodactyl.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + + result3 = await hass.config_entries.flow.async_configure( + flow_id=result2["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == TEST_HOST + assert result3["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_config_entry", "mock_pterodactyl") +async def test_service_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test config flow abort if the Pterodactyl server is already configured.""" + mock_config_entry.add_to_hass(hass) + + mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( + mock_pterodactyl, "client", TEST_SERVER_LIST_DATA + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From e2ba31e00f1b40d2359e03b8b5aa8393852a3685 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:12:25 +0000 Subject: [PATCH 02/16] Remove translation for unavailable platform sensor, use constant for host --- .../components/pterodactyl/entity.py | 3 ++- .../components/pterodactyl/icons.json | 24 ------------------- .../components/pterodactyl/strings.json | 20 ---------------- 3 files changed, 2 insertions(+), 45 deletions(-) delete mode 100644 homeassistant/components/pterodactyl/icons.json diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py index bda03e38500d72..67b1c668be3d43 100644 --- a/homeassistant/components/pterodactyl/entity.py +++ b/homeassistant/components/pterodactyl/entity.py @@ -1,6 +1,7 @@ """Base entity for the Pterodactyl integration.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -38,5 +39,5 @@ def __init__( name=coordinator.data[index].name, model=coordinator.data[index].name, model_id=coordinator.data[index].uuid, - configuration_url=f"{config_entry.data['host']}/server/{identifier}", + configuration_url=f"{config_entry.data[CONF_HOST]}/server/{identifier}", ) diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json deleted file mode 100644 index b0f6f2864c77f4..00000000000000 --- a/homeassistant/components/pterodactyl/icons.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "entity": { - "sensor": { - "cpu_usage": { - "default": "mdi:cpu-64-bit" - }, - "memory_usage": { - "default": "mdi:memory" - }, - "disk_usage": { - "default": "mdi:harddisk" - }, - "network_rx_usage": { - "default": "mdi:download" - }, - "network_tx_usage": { - "default": "mdi:upload" - }, - "uptime": { - "default": "mdi:timer" - } - } - } -} diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 691819411d2030..efcd790865c0ad 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -25,26 +25,6 @@ "status": { "name": "Status" } - }, - "sensor": { - "cpu_usage": { - "name": "CPU Usage" - }, - "memory_usage": { - "name": "Memory Usage" - }, - "disk_usage": { - "name": "Disk Usage" - }, - "network_rx_usage": { - "name": "Network RX Usage" - }, - "network_tx_usage": { - "name": "Network TX Usage" - }, - "uptime": { - "name": "Uptime" - } } } } From d88ef2d496844ac7548097ae42d9fbb80e4ebad5 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:19:44 +0000 Subject: [PATCH 03/16] Improve data descriptions --- homeassistant/components/pterodactyl/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index efcd790865c0ad..0259e07115a0fd 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -7,8 +7,8 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "The hostname or IP address of your Pterodactyl server (including the port).", - "api_key": "The client API key for accessing your Pterodactyl server." + "host": "The IP address or hostname of your Pterodactyl server, starting with either 'http://' or 'https://', optionally including the port at the end.", + "api_key": "The client (account) API key for accessing your Pterodactyl server." } } }, From 38d4b5530d761a94b6966a1dfe3ebd001e89a30b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:38:39 +0000 Subject: [PATCH 04/16] Replace index based handling of data (list) with dict[str, PterodactylData] --- homeassistant/components/pterodactyl/api.py | 25 ++++++------------- .../components/pterodactyl/binary_sensor.py | 10 +------- .../components/pterodactyl/coordinator.py | 6 ++--- .../components/pterodactyl/entity.py | 14 +++-------- 4 files changed, 14 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index b1e49620bfc934..691aabefd17e4e 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -49,7 +49,7 @@ class PterodactylAPI: pterodactyl: PterodactylClient | None identifiers: list[str] - data_list: list[PterodactylData] + data: dict[str, PterodactylData] def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: """Initialize the Pterodactyl API.""" @@ -85,18 +85,9 @@ async def async_init(self): _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) - def get_index_from_identifier(self, identifier: str) -> int | None: - """Get the index of the data list from the identifier.""" - for index, data in enumerate(self.data_list): - if data.identifier == identifier: - return index - - return None - - async def async_get_data_list(self) -> list[PterodactylData]: + async def async_get_data(self) -> dict[str, PterodactylData]: """Update the data from all Pterodactyl servers.""" - self.data_list = [] - current_identifier: str + self.data = {} if self.pterodactyl is None: raise PterodactylNotInitializedError( @@ -105,7 +96,6 @@ async def async_get_data_list(self) -> list[PterodactylData]: try: for identifier in self.identifiers: - current_identifier = identifier server = await self.hass.async_add_executor_job( self.pterodactyl.client.servers.get_server, identifier ) @@ -113,10 +103,10 @@ async def async_get_data_list(self) -> list[PterodactylData]: self.pterodactyl.client.servers.get_server_utilization, identifier ) - data = PterodactylData( + self.data[identifier] = PterodactylData( name=server["name"], uuid=server["uuid"], - identifier=current_identifier, + identifier=identifier, state=utilization["current_state"], cpu_utilization=utilization["resources"]["cpu_absolute"], memory_utilization=utilization["resources"]["memory_bytes"], @@ -126,8 +116,7 @@ async def async_get_data_list(self) -> list[PterodactylData]: uptime=utilization["resources"]["uptime"], ) - self.data_list.append(data) - _LOGGER.debug("%s", data) + _LOGGER.debug("%s", self.data[identifier]) except ( PydactylError, BadRequestError, @@ -138,4 +127,4 @@ async def async_get_data_list(self) -> list[PterodactylData]: _LOGGER.exception("Unexpected exception occurred during data update") raise PterodactylConnectionError(error) from error else: - return self.data_list + return self.data diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index b21bd1ffd7274f..5070bff1ae2075 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -6,7 +6,6 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PterodactylConfigEntry, PterodactylCoordinator @@ -71,11 +70,4 @@ def available(self) -> bool: @property def is_on(self) -> bool: """Return binary sensor state.""" - index = self.coordinator.api.get_index_from_identifier(self.identifier) - - if index is None: - raise HomeAssistantError( - f"Identifier '{self.identifier}' not found in data list" - ) - - return self.coordinator.data[index].state == "running" + return self.coordinator.data[self.identifier].state == "running" diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 0543a0a3d9c21f..fb2fdbc72e215d 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -26,7 +26,7 @@ type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator] -class PterodactylCoordinator(DataUpdateCoordinator[list[PterodactylData]]): +class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): """Pterodactyl data update coordinator.""" config_entry: PterodactylConfigEntry @@ -59,9 +59,9 @@ async def _async_setup(self) -> None: except PterodactylConfigurationError as error: raise ConfigEntryNotReady(error) from error - async def _async_update_data(self) -> list[PterodactylData]: + async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: - return await self.api.async_get_data_list() + return await self.api.async_get_data() except (PterodactylNotInitializedError, PterodactylConnectionError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py index 67b1c668be3d43..eca745d9a4d42e 100644 --- a/homeassistant/components/pterodactyl/entity.py +++ b/homeassistant/components/pterodactyl/entity.py @@ -2,7 +2,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,18 +25,11 @@ def __init__( """Initialize base entity.""" super().__init__(coordinator) - index = coordinator.api.get_index_from_identifier(identifier) - - if index is None: - raise HomeAssistantError( - f"Identifier '{identifier}' not found in data list" - ) - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, manufacturer=MANUFACTURER, - name=coordinator.data[index].name, - model=coordinator.data[index].name, - model_id=coordinator.data[index].uuid, + name=coordinator.data[identifier].name, + model=coordinator.data[identifier].name, + model_id=coordinator.data[identifier].uuid, configuration_url=f"{config_entry.data[CONF_HOST]}/server/{identifier}", ) From 8e6cbfd9afb5006730573fa82f5ebfc70251fcab Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 23 Mar 2025 23:05:24 +0000 Subject: [PATCH 05/16] Replace CONF_HOST with CONF_URL --- homeassistant/components/pterodactyl/config_flow.py | 10 +++++----- homeassistant/components/pterodactyl/coordinator.py | 6 +++--- homeassistant/components/pterodactyl/entity.py | 4 ++-- homeassistant/components/pterodactyl/strings.json | 4 ++-- tests/components/pterodactyl/conftest.py | 10 +++++----- tests/components/pterodactyl/test_config_flow.py | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index d284fc0635de41..e3b50367b40559 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_URL from .api import ( PterodactylAPI, @@ -16,11 +16,11 @@ ) from .const import DOMAIN -DEFAULT_HOST = "http://localhost:8080" +DEFAULT_URL = "http://localhost:8080" STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_URL, default=DEFAULT_URL): str, vol.Required(CONF_API_KEY): str, } ) @@ -37,10 +37,10 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] + host = user_input[CONF_URL] api_key = user_input[CONF_API_KEY] - self._async_abort_entries_match({CONF_HOST: host}) + self._async_abort_entries_match({CONF_URL: host}) api = PterodactylAPI(self.hass, host, api_key) try: diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index fb2fdbc72e215d..1c03916139304e 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -6,7 +6,7 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -41,7 +41,7 @@ def __init__( super().__init__( hass=hass, - name=config_entry.data[CONF_HOST], + name=config_entry.data[CONF_URL], config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, @@ -52,7 +52,7 @@ async def _async_setup(self) -> None: try: self.api = PterodactylAPI( hass=self.hass, - host=self.config_entry.data[CONF_HOST], + host=self.config_entry.data[CONF_URL], api_key=self.config_entry.data[CONF_API_KEY], ) await self.api.async_init() diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py index eca745d9a4d42e..c58a7d63e03832 100644 --- a/homeassistant/components/pterodactyl/entity.py +++ b/homeassistant/components/pterodactyl/entity.py @@ -1,7 +1,7 @@ """Base entity for the Pterodactyl integration.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,5 +31,5 @@ def __init__( name=coordinator.data[identifier].name, model=coordinator.data[identifier].name, model_id=coordinator.data[identifier].uuid, - configuration_url=f"{config_entry.data[CONF_HOST]}/server/{identifier}", + configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", ) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 0259e07115a0fd..8b63f911f362d3 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -3,11 +3,11 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "The IP address or hostname of your Pterodactyl server, starting with either 'http://' or 'https://', optionally including the port at the end.", + "url": "The IP address or hostname of your Pterodactyl server, starting with either 'http://' or 'https://', optionally including the port at the end.", "api_key": "The client (account) API key for accessing your Pterodactyl server." } } diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py index 444ca67bc54dba..979da6d0dae60a 100644 --- a/tests/components/pterodactyl/conftest.py +++ b/tests/components/pterodactyl/conftest.py @@ -5,14 +5,14 @@ import pytest from homeassistant.components.pterodactyl.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_URL from tests.common import MockConfigEntry -TEST_HOST = "https://192.168.0.1:8080" +TEST_URL = "https://192.168.0.1:8080" TEST_API_KEY = "TestClientApiKey" TEST_USER_INPUT = { - CONF_HOST: TEST_HOST, + CONF_URL: TEST_URL, CONF_API_KEY: TEST_API_KEY, } TEST_SERVER_LIST_DATA = { @@ -118,9 +118,9 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id="01234567890123456789012345678901", - title=TEST_HOST, + title=TEST_URL, data={ - CONF_HOST: TEST_HOST, + CONF_URL: TEST_URL, CONF_API_KEY: TEST_API_KEY, }, version=1, diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 4482bbd348e625..501b3247ea0c37 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -11,10 +11,10 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import ( - TEST_HOST, TEST_SERVER, TEST_SERVER_LIST_DATA, TEST_SERVER_UTILIZATION, + TEST_URL, TEST_USER_INPUT, ) @@ -47,7 +47,7 @@ async def test_full_flow( await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == TEST_HOST + assert result2["title"] == TEST_URL assert result2["data"] == TEST_USER_INPUT @@ -95,7 +95,7 @@ async def test_recovery_after_error( await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == TEST_HOST + assert result3["title"] == TEST_URL assert result3["data"] == TEST_USER_INPUT From 7c3510a0cc90100a723c18791a3dfd69ae9e3d18 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:06:34 +0000 Subject: [PATCH 06/16] Parse URL with YARL --- homeassistant/components/pterodactyl/config_flow.py | 9 +++++---- homeassistant/components/pterodactyl/strings.json | 3 +-- tests/components/pterodactyl/conftest.py | 2 +- tests/components/pterodactyl/test_config_flow.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index e3b50367b40559..feead2d1f9d147 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL @@ -37,18 +38,18 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_URL] + url = URL(user_input[CONF_URL]).human_repr() api_key = user_input[CONF_API_KEY] - self._async_abort_entries_match({CONF_URL: host}) - api = PterodactylAPI(self.hass, host, api_key) + self._async_abort_entries_match({CONF_URL: url}) + api = PterodactylAPI(self.hass, url, api_key) try: await api.async_init() except (PterodactylConfigurationError, PterodactylConnectionError): errors["base"] = "cannot_connect" else: - return self.async_create_entry(title=host, data=user_input) + return self.async_create_entry(title=url, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 8b63f911f362d3..5b8e2083a0d7d4 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -13,8 +13,7 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py index 979da6d0dae60a..884fe0afc7ace8 100644 --- a/tests/components/pterodactyl/conftest.py +++ b/tests/components/pterodactyl/conftest.py @@ -9,7 +9,7 @@ from tests.common import MockConfigEntry -TEST_URL = "https://192.168.0.1:8080" +TEST_URL = "https://192.168.0.1:8080/" TEST_API_KEY = "TestClientApiKey" TEST_USER_INPUT = { CONF_URL: TEST_URL, diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 501b3247ea0c37..fe944be707ed57 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -59,10 +59,10 @@ async def test_full_flow( ], ) @pytest.mark.usefixtures("mock_pterodactyl") -async def test_recovery_after_error( +async def test_recovery_after_api_error( hass: HomeAssistant, exception_type, mock_pterodactyl: PterodactylClient ) -> None: - """Test recovery after connection error.""" + """Test recovery after an API error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) From 29eca02d0cb873cf28b899940c500ceaf59cebde Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:16:38 +0000 Subject: [PATCH 07/16] Set proper availability in binary sensor --- homeassistant/components/pterodactyl/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index 5070bff1ae2075..eebd7308640930 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -65,7 +65,7 @@ def __init__( @property def available(self) -> bool: """Return binary sensor availability.""" - return True + return super().available and self.identifier in self.coordinator.data @property def is_on(self) -> bool: From 3715f7a8b6e737b31d2c93f6a53518da4c0538b7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:26:33 +0000 Subject: [PATCH 08/16] Remove storage of data within api.py --- homeassistant/components/pterodactyl/api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 691aabefd17e4e..2702d142378218 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -49,7 +49,6 @@ class PterodactylAPI: pterodactyl: PterodactylClient | None identifiers: list[str] - data: dict[str, PterodactylData] def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: """Initialize the Pterodactyl API.""" @@ -87,7 +86,7 @@ async def async_init(self): async def async_get_data(self) -> dict[str, PterodactylData]: """Update the data from all Pterodactyl servers.""" - self.data = {} + data = {} if self.pterodactyl is None: raise PterodactylNotInitializedError( @@ -103,7 +102,7 @@ async def async_get_data(self) -> dict[str, PterodactylData]: self.pterodactyl.client.servers.get_server_utilization, identifier ) - self.data[identifier] = PterodactylData( + data[identifier] = PterodactylData( name=server["name"], uuid=server["uuid"], identifier=identifier, @@ -116,7 +115,7 @@ async def async_get_data(self) -> dict[str, PterodactylData]: uptime=utilization["resources"]["uptime"], ) - _LOGGER.debug("%s", self.data[identifier]) + _LOGGER.debug("%s", data[identifier]) except ( PydactylError, BadRequestError, @@ -127,4 +126,4 @@ async def async_get_data(self) -> dict[str, PterodactylData]: _LOGGER.exception("Unexpected exception occurred during data update") raise PterodactylConnectionError(error) from error else: - return self.data + return data From 1c09e00e2ad9ad384475a5d1a9c0f84f625cb8fb Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:47:00 +0000 Subject: [PATCH 09/16] Fix some review findings --- homeassistant/components/pterodactyl/api.py | 43 ++++++++----------- .../components/pterodactyl/binary_sensor.py | 19 +++----- .../components/pterodactyl/coordinator.py | 14 +++--- .../components/pterodactyl/entity.py | 6 +++ .../components/pterodactyl/strings.json | 4 +- 5 files changed, 38 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 2702d142378218..fa75ccb753d733 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -24,10 +24,6 @@ class PterodactylConnectionError(Exception): """Raised when no data can be fechted from the server.""" -class PterodactylNotInitializedError(Exception): - """Raised when APIs are used although server instance is not initialized yet.""" - - @dataclass class PterodactylData: """Data for the Pterodactyl server.""" @@ -88,20 +84,26 @@ async def async_get_data(self) -> dict[str, PterodactylData]: """Update the data from all Pterodactyl servers.""" data = {} - if self.pterodactyl is None: - raise PterodactylNotInitializedError( - "Pterodactyl API is not initialized yet" - ) - - try: - for identifier in self.identifiers: + for identifier in self.identifiers: + try: server = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server, identifier + self.pterodactyl.client.servers.get_server, # type: ignore[union-attr] + identifier, ) utilization = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server_utilization, identifier + self.pterodactyl.client.servers.get_server_utilization, # type: ignore[union-attr] + identifier, ) - + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error + except Exception as error: + _LOGGER.exception("Unexpected exception occurred during data update") + raise PterodactylConnectionError(error) from error + else: data[identifier] = PterodactylData( name=server["name"], uuid=server["uuid"], @@ -116,14 +118,5 @@ async def async_get_data(self) -> dict[str, PterodactylData]: ) _LOGGER.debug("%s", data[identifier]) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: - raise PterodactylConnectionError(error) from error - except Exception as error: - _LOGGER.exception("Unexpected exception occurred during data update") - raise PterodactylConnectionError(error) from error - else: - return data + + return data diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index eebd7308640930..05086678c5c6ac 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -35,13 +35,11 @@ async def async_setup_entry( coordinator = config_entry.runtime_data async_add_entities( - [ - PterodactylBinarySensorEntity( - coordinator, identifier, description, config_entry - ) - for identifier in coordinator.api.identifiers - for description in BINARY_SENSOR_DESCRIPTIONS - ] + PterodactylBinarySensorEntity( + coordinator, identifier, description, config_entry + ) + for identifier in coordinator.api.identifiers + for description in BINARY_SENSOR_DESCRIPTIONS ) @@ -58,14 +56,7 @@ def __init__( """Initialize binary sensor base entity.""" super().__init__(coordinator, identifier, config_entry) self.entity_description = description - self.identifier = identifier self._attr_unique_id = f"{config_entry.entry_id}-{identifier}-{description.key}" - self._attr_is_on = False - - @property - def available(self) -> bool: - """Return binary sensor availability.""" - return super().available and self.identifier in self.coordinator.data @property def is_on(self) -> bool: diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 1c03916139304e..1450313d54453d 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -16,7 +16,6 @@ PterodactylConfigurationError, PterodactylConnectionError, PterodactylData, - PterodactylNotInitializedError, ) SCAN_INTERVAL = timedelta(seconds=60) @@ -49,12 +48,13 @@ def __init__( async def _async_setup(self) -> None: """Set up the Pterodactyl data coordinator.""" + self.api = PterodactylAPI( + hass=self.hass, + host=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + ) + try: - self.api = PterodactylAPI( - hass=self.hass, - host=self.config_entry.data[CONF_URL], - api_key=self.config_entry.data[CONF_API_KEY], - ) await self.api.async_init() except PterodactylConfigurationError as error: raise ConfigEntryNotReady(error) from error @@ -63,5 +63,5 @@ async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: return await self.api.async_get_data() - except (PterodactylNotInitializedError, PterodactylConnectionError) as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py index c58a7d63e03832..63886bf655c3a5 100644 --- a/homeassistant/components/pterodactyl/entity.py +++ b/homeassistant/components/pterodactyl/entity.py @@ -25,6 +25,7 @@ def __init__( """Initialize base entity.""" super().__init__(coordinator) + self.identifier = identifier self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, manufacturer=MANUFACTURER, @@ -33,3 +34,8 @@ def __init__( model_id=coordinator.data[identifier].uuid, configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", ) + + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return super().available and self.identifier in self.coordinator.data diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 5b8e2083a0d7d4..fec2c1595f2ba2 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -8,7 +8,7 @@ }, "data_description": { "url": "The IP address or hostname of your Pterodactyl server, starting with either 'http://' or 'https://', optionally including the port at the end.", - "api_key": "The client (account) API key for accessing your Pterodactyl server." + "api_key": "The account API key for accessing your Pterodactyl server." } } }, @@ -16,7 +16,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "entity": { From b98c76e673b725bed11e55ccac43ed381bec2da6 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:54:00 +0000 Subject: [PATCH 10/16] Use better unique ID for binary_sensor --- homeassistant/components/pterodactyl/binary_sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index 05086678c5c6ac..10e34141be2a8e 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -56,7 +56,9 @@ def __init__( """Initialize binary sensor base entity.""" super().__init__(coordinator, identifier, config_entry) self.entity_description = description - self._attr_unique_id = f"{config_entry.entry_id}-{identifier}-{description.key}" + self._attr_unique_id = ( + f"{self.coordinator.data[self.identifier].uuid}-{description.key}" + ) @property def is_on(self) -> bool: From e8ff20b34ffdd4ac57c25bfc9bb3c97e249c1f8c Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:31:11 +0000 Subject: [PATCH 11/16] Fix more review findings --- homeassistant/components/pterodactyl/api.py | 29 ++++++------- .../components/pterodactyl/binary_sensor.py | 6 +-- .../components/pterodactyl/config_flow.py | 6 +++ .../components/pterodactyl/entity.py | 12 ++++-- .../components/pterodactyl/strings.json | 3 +- .../pterodactyl/test_config_flow.py | 41 +++++++++++++++++++ 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index fa75ccb753d733..6b0665688e818a 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -1,5 +1,6 @@ """API module of the Pterodactyl integration.""" +from asyncio import gather from dataclasses import dataclass import logging @@ -70,13 +71,10 @@ async def async_init(self): PterodactylApiError, ) as error: raise PterodactylConnectionError(error) from error - except Exception as error: - _LOGGER.exception("Unexpected exception occurred during initialization") - raise PterodactylConnectionError(error) from error else: - servers = paginated_response.collect() - for server in servers: - self.identifiers.append(server["attributes"]["identifier"]) + game_servers = paginated_response.collect() + for game_server in game_servers: + self.identifiers.append(game_server["attributes"]["identifier"]) _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) @@ -86,13 +84,15 @@ async def async_get_data(self) -> dict[str, PterodactylData]: for identifier in self.identifiers: try: - server = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server, # type: ignore[union-attr] - identifier, - ) - utilization = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server_utilization, # type: ignore[union-attr] - identifier, + server, utilization = await gather( + self.hass.async_add_executor_job( + self.pterodactyl.client.servers.get_server, # type: ignore[union-attr] + identifier, + ), + self.hass.async_add_executor_job( + self.pterodactyl.client.servers.get_server_utilization, # type: ignore[union-attr] + identifier, + ), ) except ( PydactylError, @@ -100,9 +100,6 @@ async def async_get_data(self) -> dict[str, PterodactylData]: PterodactylApiError, ) as error: raise PterodactylConnectionError(error) from error - except Exception as error: - _LOGGER.exception("Unexpected exception occurred during data update") - raise PterodactylConnectionError(error) from error else: data[identifier] = PterodactylData( name=server["name"], diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index 10e34141be2a8e..1cca3c8de48ed2 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -56,11 +56,9 @@ def __init__( """Initialize binary sensor base entity.""" super().__init__(coordinator, identifier, config_entry) self.entity_description = description - self._attr_unique_id = ( - f"{self.coordinator.data[self.identifier].uuid}-{description.key}" - ) + self._attr_unique_id = f"{self.game_server_data.uuid}-{description.key}" @property def is_on(self) -> bool: """Return binary sensor state.""" - return self.coordinator.data[self.identifier].state == "running" + return self.game_server_data.state == "running" diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index feead2d1f9d147..a36069d2bb924e 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any import voluptuous as vol @@ -17,6 +18,8 @@ ) from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DEFAULT_URL = "http://localhost:8080" STEP_USER_DATA_SCHEMA = vol.Schema( @@ -48,6 +51,9 @@ async def async_step_user( await api.async_init() except (PterodactylConfigurationError, PterodactylConnectionError): errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" else: return self.async_create_entry(title=url, data=user_input) diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py index 63886bf655c3a5..49fd65af4765d2 100644 --- a/homeassistant/components/pterodactyl/entity.py +++ b/homeassistant/components/pterodactyl/entity.py @@ -5,6 +5,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import PterodactylData from .const import DOMAIN from .coordinator import PterodactylCoordinator @@ -29,9 +30,9 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, manufacturer=MANUFACTURER, - name=coordinator.data[identifier].name, - model=coordinator.data[identifier].name, - model_id=coordinator.data[identifier].uuid, + name=self.game_server_data.name, + model=self.game_server_data.name, + model_id=self.game_server_data.uuid, configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", ) @@ -39,3 +40,8 @@ def __init__( def available(self) -> bool: """Return binary sensor availability.""" return super().available and self.identifier in self.coordinator.data + + @property + def game_server_data(self) -> PterodactylData: + """Return game server data.""" + return self.coordinator.data[self.identifier] diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index fec2c1595f2ba2..2072dba1e22524 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -13,7 +13,8 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index fe944be707ed57..b6ad2b4bb24a02 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -99,6 +99,47 @@ async def test_recovery_after_api_error( assert result3["data"] == TEST_USER_INPUT +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_recovery_after_unknown_error( + hass: HomeAssistant, mock_pterodactyl: PterodactylClient +) -> None: + """Test recovery after an API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = Exception + + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + mock_pterodactyl.reset_mock(side_effect=True) + mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( + mock_pterodactyl, "client", TEST_SERVER_LIST_DATA + ) + mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER + mock_pterodactyl.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + + result3 = await hass.config_entries.flow.async_configure( + flow_id=result2["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == TEST_URL + assert result3["data"] == TEST_USER_INPUT + + @pytest.mark.usefixtures("mock_config_entry", "mock_pterodactyl") async def test_service_already_configured( hass: HomeAssistant, From a58bad43ea68921629e09825b8952fdd5d4c1d20 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:40:34 +0000 Subject: [PATCH 12/16] Fix remaining review findings --- homeassistant/components/pterodactyl/coordinator.py | 3 +-- homeassistant/components/pterodactyl/strings.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 1450313d54453d..36456ade63060f 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( @@ -57,7 +56,7 @@ async def _async_setup(self) -> None: try: await self.api.async_init() except PterodactylConfigurationError as error: - raise ConfigEntryNotReady(error) from error + raise UpdateFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 2072dba1e22524..a875c72ccd8b3a 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -7,7 +7,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "url": "The IP address or hostname of your Pterodactyl server, starting with either 'http://' or 'https://', optionally including the port at the end.", + "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", "api_key": "The account API key for accessing your Pterodactyl server." } } From 42e112d818bf8445ea5e98d6d6a0e6342b0f6836 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:33:36 +0000 Subject: [PATCH 13/16] Add wrapper for server and util API, use underscore in unique ID --- homeassistant/components/pterodactyl/api.py | 21 ++++++++++--------- .../components/pterodactyl/binary_sensor.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 6b0665688e818a..38cb980965294a 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -1,6 +1,5 @@ """API module of the Pterodactyl integration.""" -from asyncio import gather from dataclasses import dataclass import logging @@ -78,21 +77,23 @@ async def async_init(self): _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) + def get_server_data(self, identifier: str) -> tuple[dict, dict]: + """Get all data from the Pterodactyl server.""" + server = self.pterodactyl.client.servers.get_server(identifier) # type: ignore[union-attr] + utilization = self.pterodactyl.client.servers.get_server_utilization( # type: ignore[union-attr] + identifier + ) + + return server, utilization + async def async_get_data(self) -> dict[str, PterodactylData]: """Update the data from all Pterodactyl servers.""" data = {} for identifier in self.identifiers: try: - server, utilization = await gather( - self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server, # type: ignore[union-attr] - identifier, - ), - self.hass.async_add_executor_job( - self.pterodactyl.client.servers.get_server_utilization, # type: ignore[union-attr] - identifier, - ), + server, utilization = await self.hass.async_add_executor_job( + self.get_server_data, identifier ) except ( PydactylError, diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py index 1cca3c8de48ed2..e3615c474998ab 100644 --- a/homeassistant/components/pterodactyl/binary_sensor.py +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -56,7 +56,7 @@ def __init__( """Initialize binary sensor base entity.""" super().__init__(coordinator, identifier, config_entry) self.entity_description = description - self._attr_unique_id = f"{self.game_server_data.uuid}-{description.key}" + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" @property def is_on(self) -> bool: From a1be64996a0e2c82ec74a967b1aa7166508af475 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:36:44 +0000 Subject: [PATCH 14/16] Reuse result in config flow tests --- .../pterodactyl/test_config_flow.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index b6ad2b4bb24a02..cc38f54c7dde29 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -40,15 +40,15 @@ async def test_full_flow( TEST_SERVER_UTILIZATION ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == TEST_URL - assert result2["data"] == TEST_USER_INPUT + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT @pytest.mark.parametrize( @@ -71,14 +71,14 @@ async def test_recovery_after_api_error( mock_pterodactyl.client.servers.list_servers.side_effect = exception_type - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} mock_pterodactyl.reset_mock(side_effect=True) mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( @@ -89,14 +89,14 @@ async def test_recovery_after_api_error( TEST_SERVER_UTILIZATION ) - result3 = await hass.config_entries.flow.async_configure( - flow_id=result2["flow_id"], user_input=TEST_USER_INPUT + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == TEST_URL - assert result3["data"] == TEST_USER_INPUT + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT @pytest.mark.usefixtures("mock_pterodactyl") @@ -112,14 +112,14 @@ async def test_recovery_after_unknown_error( mock_pterodactyl.client.servers.list_servers.side_effect = Exception - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} mock_pterodactyl.reset_mock(side_effect=True) mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( @@ -130,14 +130,14 @@ async def test_recovery_after_unknown_error( TEST_SERVER_UTILIZATION ) - result3 = await hass.config_entries.flow.async_configure( - flow_id=result2["flow_id"], user_input=TEST_USER_INPUT + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == TEST_URL - assert result3["data"] == TEST_USER_INPUT + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT @pytest.mark.usefixtures("mock_config_entry", "mock_pterodactyl") From ae9c297f81a005810170071b715a86178e3efb23 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:48:54 +0000 Subject: [PATCH 15/16] Patch async_setup_entry in config_flow tests --- tests/components/pterodactyl/conftest.py | 12 ++++++++++- .../pterodactyl/test_config_flow.py | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py index 884fe0afc7ace8..f23ff28c2af4e6 100644 --- a/tests/components/pterodactyl/conftest.py +++ b/tests/components/pterodactyl/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Pterodactyl tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest @@ -111,6 +112,15 @@ } +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.pterodactyl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Create Pterodactyl mock config entry.""" diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index cc38f54c7dde29..30d5f01fb753e5 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,5 +1,8 @@ """Test the Pterodactyl config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock + from pydactyl import PterodactylClient from pydactyl.exceptions import ClientConfigError, PterodactylApiError from pydactyl.responses import PaginatedResponse @@ -21,9 +24,10 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_pterodactyl") async def test_full_flow( - hass: HomeAssistant, mock_pterodactyl: PterodactylClient + hass: HomeAssistant, + mock_pterodactyl: PterodactylClient, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test full flow without errors.""" result = await hass.config_entries.flow.async_init( @@ -58,9 +62,11 @@ async def test_full_flow( PterodactylApiError, ], ) -@pytest.mark.usefixtures("mock_pterodactyl") async def test_recovery_after_api_error( - hass: HomeAssistant, exception_type, mock_pterodactyl: PterodactylClient + hass: HomeAssistant, + exception_type, + mock_pterodactyl: PterodactylClient, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test recovery after an API error.""" result = await hass.config_entries.flow.async_init( @@ -99,9 +105,10 @@ async def test_recovery_after_api_error( assert result["data"] == TEST_USER_INPUT -@pytest.mark.usefixtures("mock_pterodactyl") async def test_recovery_after_unknown_error( - hass: HomeAssistant, mock_pterodactyl: PterodactylClient + hass: HomeAssistant, + mock_pterodactyl: PterodactylClient, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test recovery after an API error.""" result = await hass.config_entries.flow.async_init( @@ -140,11 +147,11 @@ async def test_recovery_after_unknown_error( assert result["data"] == TEST_USER_INPUT -@pytest.mark.usefixtures("mock_config_entry", "mock_pterodactyl") async def test_service_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pterodactyl: PterodactylClient, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test config flow abort if the Pterodactyl server is already configured.""" mock_config_entry.add_to_hass(hass) From eec2835cb656ec6d07a4aa38c42347bf6146c4ab Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:08:19 +0000 Subject: [PATCH 16/16] Move patching of library APIs to the fixture mock_pterodactyl --- tests/components/pterodactyl/conftest.py | 9 ++++ .../pterodactyl/test_config_flow.py | 52 +++---------------- 2 files changed, 16 insertions(+), 45 deletions(-) diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py index f23ff28c2af4e6..62326e79207bec 100644 --- a/tests/components/pterodactyl/conftest.py +++ b/tests/components/pterodactyl/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pydactyl.responses import PaginatedResponse import pytest from homeassistant.components.pterodactyl.const import DOMAIN @@ -143,4 +144,12 @@ def mock_pterodactyl(): with patch( "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True ) as mock: + mock.return_value.client.servers.list_servers.return_value = PaginatedResponse( + mock.return_value, "client", TEST_SERVER_LIST_DATA + ) + mock.return_value.client.servers.get_server.return_value = TEST_SERVER + mock.return_value.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + yield mock.return_value diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 30d5f01fb753e5..14bb2d2f69f7e8 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,11 +1,7 @@ """Test the Pterodactyl config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock - from pydactyl import PterodactylClient from pydactyl.exceptions import ClientConfigError, PterodactylApiError -from pydactyl.responses import PaginatedResponse import pytest from homeassistant.components.pterodactyl.const import DOMAIN @@ -13,22 +9,13 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import ( - TEST_SERVER, - TEST_SERVER_LIST_DATA, - TEST_SERVER_UTILIZATION, - TEST_URL, - TEST_USER_INPUT, -) +from .conftest import TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry -async def test_full_flow( - hass: HomeAssistant, - mock_pterodactyl: PterodactylClient, - mock_setup_entry: Generator[AsyncMock], -) -> None: +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: """Test full flow without errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -36,14 +23,6 @@ async def test_full_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( - mock_pterodactyl, "client", TEST_SERVER_LIST_DATA - ) - mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER - mock_pterodactyl.client.servers.get_server_utilization.return_value = ( - TEST_SERVER_UTILIZATION - ) - result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT, @@ -55,6 +34,7 @@ async def test_full_flow( assert result["data"] == TEST_USER_INPUT +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( "exception_type", [ @@ -66,7 +46,6 @@ async def test_recovery_after_api_error( hass: HomeAssistant, exception_type, mock_pterodactyl: PterodactylClient, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test recovery after an API error.""" result = await hass.config_entries.flow.async_init( @@ -87,13 +66,6 @@ async def test_recovery_after_api_error( assert result["errors"] == {"base": "cannot_connect"} mock_pterodactyl.reset_mock(side_effect=True) - mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( - mock_pterodactyl, "client", TEST_SERVER_LIST_DATA - ) - mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER - mock_pterodactyl.client.servers.get_server_utilization.return_value = ( - TEST_SERVER_UTILIZATION - ) result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT @@ -105,10 +77,10 @@ async def test_recovery_after_api_error( assert result["data"] == TEST_USER_INPUT +@pytest.mark.usefixtures("mock_setup_entry") async def test_recovery_after_unknown_error( hass: HomeAssistant, mock_pterodactyl: PterodactylClient, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test recovery after an API error.""" result = await hass.config_entries.flow.async_init( @@ -129,13 +101,6 @@ async def test_recovery_after_unknown_error( assert result["errors"] == {"base": "unknown"} mock_pterodactyl.reset_mock(side_effect=True) - mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( - mock_pterodactyl, "client", TEST_SERVER_LIST_DATA - ) - mock_pterodactyl.client.servers.get_server.return_value = TEST_SERVER - mock_pterodactyl.client.servers.get_server_utilization.return_value = ( - TEST_SERVER_UTILIZATION - ) result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=TEST_USER_INPUT @@ -147,21 +112,18 @@ async def test_recovery_after_unknown_error( assert result["data"] == TEST_USER_INPUT +@pytest.mark.usefixtures("mock_setup_entry") async def test_service_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pterodactyl: PterodactylClient, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test config flow abort if the Pterodactyl server is already configured.""" mock_config_entry.add_to_hass(hass) - mock_pterodactyl.client.servers.list_servers.return_value = PaginatedResponse( - mock_pterodactyl, "client", TEST_SERVER_LIST_DATA - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured"