Add config flow to pyLoad integration (#120135)

* Add config flow to pyLoad integration

* address issues

* remove suggested values

* Fix exception

* abort import flow on error

* readd repair issues on error

* fix ruff

* changes

* changes

* exception hints
This commit is contained in:
Mr. Bubbles 2024-06-23 12:34:32 +02:00 committed by GitHub
parent f257fcb0d1
commit 28fb361c64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1041 additions and 112 deletions

View file

@ -1 +1,70 @@
"""The pyload component."""
"""The pyLoad integration."""
from __future__ import annotations
from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
PLATFORMS: list[Platform] = [Platform.SENSOR]
type PyLoadConfigEntry = ConfigEntry[PyLoadAPI]
async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool:
"""Set up pyLoad from a config entry."""
url = (
f"{"https" if entry.data[CONF_SSL] else "http"}://"
f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/"
)
session = async_create_clientsession(
hass,
verify_ssl=entry.data[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
)
pyloadapi = PyLoadAPI(
session,
api_url=url,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
await pyloadapi.login()
except CannotConnect as e:
raise ConfigEntryNotReady(
"Unable to connect and retrieve data from pyLoad API"
) from e
except ParserError as e:
raise ConfigEntryNotReady("Unable to parse data from pyLoad API") from e
except InvalidAuth as e:
raise ConfigEntryError(
f"Authentication failed for {entry.data[CONF_USERNAME]}, check your login credentials"
) from e
entry.runtime_data = pyloadapi
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,120 @@
"""Config flow for pyLoad integration."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Required(CONF_SSL, default=False): cv.boolean,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None:
"""Validate the user input and try to connect to PyLoad."""
session = async_create_clientsession(
hass,
user_input[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
)
url = (
f"{"https" if user_input[CONF_SSL] else "http"}://"
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/"
)
pyload = PyLoadAPI(
session,
api_url=url,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
await pyload.login()
class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for pyLoad."""
VERSION = 1
# store values from yaml import so we can use them as
# suggested values when the configuration step is resumed
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:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
try:
await validate_input(self.hass, user_input)
except (CannotConnect, ParserError):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
title = user_input.pop(CONF_NAME, DEFAULT_NAME)
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
config = {
CONF_NAME: import_info.get(CONF_NAME),
CONF_HOST: import_info.get(CONF_HOST, DEFAULT_HOST),
CONF_PASSWORD: import_info.get(CONF_PASSWORD, ""),
CONF_PORT: import_info.get(CONF_PORT, DEFAULT_PORT),
CONF_SSL: import_info.get(CONF_SSL, False),
CONF_USERNAME: import_info.get(CONF_USERNAME, ""),
CONF_VERIFY_SSL: False,
}
result = await self.async_step_user(config)
if errors := result.get("errors"):
return self.async_abort(reason=errors["base"])
return result

View file

@ -5,3 +5,5 @@ DOMAIN = "pyload"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "pyLoad"
DEFAULT_PORT = 8000
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=pyload"}

View file

@ -2,6 +2,7 @@
"domain": "pyload",
"name": "pyLoad",
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pyload",
"integration_type": "service",
"iot_class": "local_polling",

View file

@ -7,7 +7,6 @@ from enum import StrEnum
import logging
from time import monotonic
from aiohttp import CookieJar
from pyloadapi import (
CannotConnect,
InvalidAuth,
@ -23,6 +22,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_VARIABLES,
@ -33,14 +33,15 @@ from homeassistant.const import (
CONF_USERNAME,
UnitOfDataRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT
from . import PyLoadConfigEntry
from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN, ISSUE_PLACEHOLDER
_LOGGER = logging.getLogger(__name__)
@ -82,41 +83,63 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the pyLoad sensors."""
host = config[CONF_HOST]
port = config[CONF_PORT]
protocol = "https" if config[CONF_SSL] else "http"
name = config[CONF_NAME]
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
url = f"{protocol}://{host}:{port}/"
"""Import config from yaml."""
session = async_create_clientsession(
hass,
verify_ssl=False,
cookie_jar=CookieJar(unsafe=True),
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
pyloadapi = PyLoadAPI(session, api_url=url, username=username, password=password)
try:
await pyloadapi.login()
except CannotConnect as conn_err:
raise PlatformNotReady(
"Unable to connect and retrieve data from pyLoad API"
) from conn_err
except ParserError as e:
raise PlatformNotReady("Unable to parse data from pyLoad API") from e
except InvalidAuth as e:
raise PlatformNotReady(
f"Authentication failed for {config[CONF_USERNAME]}, check your login credentials"
) from e
_LOGGER.debug(result)
if (
result.get("type") == FlowResultType.CREATE_ENTRY
or result.get("reason") == "already_configured"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2025.2.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "pyLoad",
},
)
elif error := result.get("reason"):
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{error}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{error}",
translation_placeholders=ISSUE_PLACEHOLDER,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PyLoadConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the pyLoad sensors."""
pyloadapi = entry.runtime_data
async_add_entities(
(
PyLoadSensor(
api=pyloadapi, entity_description=description, client_name=name
api=pyloadapi,
entity_description=description,
client_name=entry.title,
entry_id=entry.entry_id,
)
for description in SENSOR_DESCRIPTIONS
),
@ -128,12 +151,17 @@ class PyLoadSensor(SensorEntity):
"""Representation of a pyLoad sensor."""
def __init__(
self, api: PyLoadAPI, entity_description: SensorEntityDescription, client_name
self,
api: PyLoadAPI,
entity_description: SensorEntityDescription,
client_name: str,
entry_id: str,
) -> None:
"""Initialize a new pyLoad sensor."""
self._attr_name = f"{client_name} {entity_description.name}"
self.type = entity_description.key
self.api = api
self._attr_unique_id = f"{entry_id}_{entity_description.key}"
self.entity_description = entity_description
self._attr_available = False
self.data: StatusServerResponse

View file

@ -0,0 +1,44 @@
{
"config": {
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"name": "The name to use for your pyLoad instance in Home Assistant",
"host": "The hostname or IP address of the device running your pyLoad instance.",
"port": "pyLoad uses port 8000 by default."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The pyLoad YAML configuration import failed",
"description": "Configuring pyLoad using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_invalid_auth": {
"title": "The pyLoad YAML configuration import failed",
"description": "Configuring pyLoad using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_unknown": {
"title": "The pyLoad YAML configuration import failed",
"description": "Configuring pyLoad using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}

View file

@ -435,6 +435,7 @@ FLOWS = {
"pushover",
"pvoutput",
"pvpc_hourly_pricing",
"pyload",
"qbittorrent",
"qingping",
"qnap",

View file

@ -4781,7 +4781,7 @@
"pyload": {
"name": "pyLoad",
"integration_type": "service",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"python_script": {

View file

@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
from pyloadapi.types import LoginResponse, StatusServerResponse
import pytest
from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_VARIABLES,
@ -15,25 +16,46 @@ from homeassistant.const import (
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.helpers.typing import ConfigType
from tests.common import MockConfigEntry
USER_INPUT = {
CONF_HOST: "pyload.local",
CONF_PASSWORD: "test-password",
CONF_PORT: 8000,
CONF_SSL: True,
CONF_USERNAME: "test-username",
CONF_VERIFY_SSL: False,
}
YAML_INPUT = {
CONF_HOST: "pyload.local",
CONF_MONITORED_VARIABLES: ["speed"],
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_PLATFORM: "pyload",
CONF_PORT: 8000,
CONF_SSL: True,
CONF_USERNAME: "test-username",
}
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.pyload.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def pyload_config() -> ConfigType:
"""Mock pyload configuration entry."""
return {
"sensor": {
CONF_PLATFORM: "pyload",
CONF_HOST: "localhost",
CONF_PORT: 8000,
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_SSL: True,
CONF_MONITORED_VARIABLES: ["speed"],
CONF_NAME: "pyload",
}
}
return {"sensor": YAML_INPUT}
@pytest.fixture
@ -41,12 +63,15 @@ def mock_pyloadapi() -> Generator[AsyncMock, None, None]:
"""Mock PyLoadAPI."""
with (
patch(
"homeassistant.components.pyload.sensor.PyLoadAPI",
autospec=True,
"homeassistant.components.pyload.PyLoadAPI", autospec=True
) as mock_client,
patch("homeassistant.components.pyload.config_flow.PyLoadAPI", new=mock_client),
patch("homeassistant.components.pyload.sensor.PyLoadAPI", new=mock_client),
):
client = mock_client.return_value
client.username = "username"
client.api_url = "https://pyload.local:8000/"
client.login.return_value = LoginResponse(
{
"_permanent": True,
@ -75,3 +100,11 @@ def mock_pyloadapi() -> Generator[AsyncMock, None, None]:
client.free_space.return_value = 99999999999
yield client
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Mock pyLoad configuration entry."""
return MockConfigEntry(
domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX"
)

View file

@ -1,4 +1,328 @@
# serializer version: 1
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'pyLoad Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'pyLoad Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'pyLoad Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensors_unavailable[CannotConnect][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensors_unavailable[CannotConnect][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensors_unavailable[InvalidAuth][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensors_unavailable[InvalidAuth][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensors_unavailable[ParserError][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_sensors_unavailable[ParserError][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_setup
StateSnapshot({
'attributes': ReadOnlyDict({
@ -14,3 +338,57 @@
'state': '5.405963',
})
# ---
# name: test_setup[sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'pyLoad Speed',
'platform': 'pyload',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
})
# ---
# name: test_setup[sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.405963',
})
# ---

View file

@ -0,0 +1,166 @@
"""Test the pyLoad config flow."""
from unittest.mock import AsyncMock
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import USER_INPUT, YAML_INPUT
from tests.common import MockConfigEntry
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(InvalidAuth, "invalid_auth"),
(CannotConnect, "cannot_connect"),
(ParserError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_pyloadapi: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_pyloadapi.login.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
mock_pyloadapi.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
async def test_flow_user_already_configured(
hass: HomeAssistant, config_entry: MockConfigEntry, mock_pyloadapi: AsyncMock
) -> None:
"""Test we abort user data set when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=USER_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_import(
hass: HomeAssistant,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test that we can import a YAML config."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == USER_INPUT
async def test_flow_import_already_configured(
hass: HomeAssistant, config_entry: MockConfigEntry, mock_pyloadapi: AsyncMock
) -> None:
"""Test we abort import data set when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "reason"),
[
(InvalidAuth, "invalid_auth"),
(CannotConnect, "cannot_connect"),
(ParserError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_flow_import_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
exception: Exception,
reason: str,
) -> None:
"""Test we abort import data set when entry is already configured."""
mock_pyloadapi.login.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason

View file

@ -0,0 +1,63 @@
"""Test pyLoad init."""
from unittest.mock import MagicMock
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_entry_setup_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
) -> None:
"""Test integration setup and unload."""
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 hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("side_effect"),
[CannotConnect, ParserError],
)
async def test_config_entry_setup_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
side_effect: Exception,
) -> None:
"""Test config entry not ready."""
mock_pyloadapi.login.side_effect = side_effect
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.SETUP_RETRY
async def test_config_entry_setup_invalid_auth(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
) -> None:
"""Test config entry authentication."""
mock_pyloadapi.login.side_effect = InvalidAuth
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.SETUP_ERROR

View file

@ -7,108 +7,74 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.pyload.const import DOMAIN
from homeassistant.components.pyload.sensor import SCAN_INTERVAL
from homeassistant.components.sensor import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
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
from homeassistant.setup import async_setup_component
from tests.common import async_fire_time_changed
SENSORS = ["sensor.pyload_speed"]
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("mock_pyloadapi")
async def test_setup(
hass: HomeAssistant,
pyload_config: ConfigType,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test setup of the pyload sensor platform."""
assert await async_setup_component(hass, DOMAIN, pyload_config)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
for sensor in SENSORS:
result = hass.states.get(sensor)
assert result == snapshot
assert config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
("exception", "expected_exception"),
[
(CannotConnect, "Unable to connect and retrieve data from pyLoad API"),
(ParserError, "Unable to parse data from pyLoad API"),
(
InvalidAuth,
"Authentication failed for username, check your login credentials",
),
],
)
async def test_setup_exceptions(
hass: HomeAssistant,
pyload_config: ConfigType,
mock_pyloadapi: AsyncMock,
exception: Exception,
expected_exception: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test exceptions during setup up pyLoad platform."""
mock_pyloadapi.login.side_effect = exception
assert await async_setup_component(hass, DOMAIN, pyload_config)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 0
assert expected_exception in caplog.text
@pytest.mark.parametrize(
("exception", "expected_exception"),
[
(CannotConnect, "Unable to connect and retrieve data from pyLoad API"),
(ParserError, "Unable to parse data from pyLoad API"),
(InvalidAuth, "Authentication failed, trying to reauthenticate"),
],
"exception",
[CannotConnect, InvalidAuth, ParserError],
)
async def test_sensor_update_exceptions(
hass: HomeAssistant,
pyload_config: ConfigType,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
exception: Exception,
expected_exception: str,
caplog: pytest.LogCaptureFixture,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test exceptions during update of pyLoad sensor."""
"""Test if pyLoad sensors go unavailable when exceptions occur (except ParserErrors)."""
mock_pyloadapi.get_status.side_effect = exception
assert await async_setup_component(hass, DOMAIN, pyload_config)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 1
assert expected_exception in caplog.text
mock_pyloadapi.get_status.side_effect = exception
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
for sensor in SENSORS:
assert hass.states.get(sensor).state == STATE_UNAVAILABLE
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_sensor_invalid_auth(
hass: HomeAssistant,
pyload_config: ConfigType,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test invalid auth during sensor update."""
assert await async_setup_component(hass, DOMAIN, pyload_config)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 1
mock_pyloadapi.get_status.side_effect = InvalidAuth
mock_pyloadapi.login.side_effect = InvalidAuth
@ -121,3 +87,61 @@ async def test_sensor_invalid_auth(
"Authentication failed for username, check your login credentials"
in caplog.text
)
async def test_platform_setup_triggers_import_flow(
hass: HomeAssistant,
pyload_config: ConfigType,
mock_setup_entry: AsyncMock,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test if an issue is created when attempting setup from yaml config."""
assert await async_setup_component(hass, SENSOR_DOMAIN, pyload_config)
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "reason"),
[
(InvalidAuth, "invalid_auth"),
(CannotConnect, "cannot_connect"),
(ParserError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_deprecated_yaml_import_issue(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
pyload_config: ConfigType,
mock_pyloadapi: AsyncMock,
exception: Exception,
reason: str,
) -> None:
"""Test an issue is created when attempting setup from yaml config and an error happens."""
mock_pyloadapi.login.side_effect = exception
await async_setup_component(hass, SENSOR_DOMAIN, pyload_config)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(
domain=DOMAIN, issue_id=f"deprecated_yaml_import_issue_{reason}"
)
async def test_deprecated_yaml(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
pyload_config: ConfigType,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test an issue is created when we import from yaml config."""
await async_setup_component(hass, SENSOR_DOMAIN, pyload_config)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(
domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}"
)