diff --git a/.coveragerc b/.coveragerc index f409745475f..513f2eff055 100644 --- a/.coveragerc +++ b/.coveragerc @@ -863,6 +863,7 @@ omit = homeassistant/components/pushbullet/sensor.py homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py + homeassistant/components/pvoutput/__init__.py homeassistant/components/pvoutput/sensor.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bbcfadaf3bf..5176344d743 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -720,6 +720,7 @@ tests/components/ps4/* @ktnrg45 homeassistant/components/push/* @dgomes tests/components/push/* @dgomes homeassistant/components/pvoutput/* @fabaff @frenck +tests/components/pvoutput/* @fabaff @frenck homeassistant/components/pvpc_hourly_pricing/* @azogue tests/components/pvpc_hourly_pricing/* @azogue homeassistant/components/qbittorrent/* @geoffreylagaisse diff --git a/homeassistant/components/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py index 0ea3aabe9eb..aaac29ef50e 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -1 +1,42 @@ -"""The pvoutput component.""" +"""The PVOutput integration.""" +from __future__ import annotations + +from pvo import PVOutput, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_SYSTEM_ID, DOMAIN, LOGGER, PLATFORMS, SCAN_INTERVAL + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PVOutput from a config entry.""" + pvoutput = PVOutput( + api_key=entry.data[CONF_API_KEY], + system_id=entry.data[CONF_SYSTEM_ID], + session=async_get_clientsession(hass), + ) + + coordinator: DataUpdateCoordinator[Status] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_{entry.data[CONF_SYSTEM_ID]}", + update_interval=SCAN_INTERVAL, + update_method=pvoutput.status, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload PVOutput config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py new file mode 100644 index 00000000000..0de24e9ae0e --- /dev/null +++ b/homeassistant/components/pvoutput/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow to configure the PVOutput integration.""" +from __future__ import annotations + +from typing import Any + +from pvo import PVOutput, PVOutputAuthenticationError, PVOutputError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SYSTEM_ID, DOMAIN + + +async def validate_input(hass: HomeAssistant, *, api_key: str, system_id: int) -> None: + """Try using the give system id & api key against the PVOutput API.""" + session = async_get_clientsession(hass) + pvoutput = PVOutput( + session=session, + api_key=api_key, + system_id=system_id, + ) + await pvoutput.status() + + +class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for PVOutput.""" + + VERSION = 1 + + imported_name: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await validate_input( + self.hass, + api_key=user_input[CONF_API_KEY], + system_id=user_input[CONF_SYSTEM_ID], + ) + except PVOutputAuthenticationError: + errors["base"] = "invalid_auth" + except PVOutputError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(str(user_input[CONF_SYSTEM_ID])) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.imported_name or str(user_input[CONF_SYSTEM_ID]), + data={ + CONF_SYSTEM_ID: user_input[CONF_SYSTEM_ID], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + description_placeholders={ + "account_url": "https://pvoutput.org/account.jsp" + }, + data_schema=vol.Schema( + { + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): str, + vol.Required( + CONF_SYSTEM_ID, default=user_input.get(CONF_SYSTEM_ID, "") + ): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self.imported_name = config[CONF_NAME] + return await self.async_step_user( + user_input={ + CONF_SYSTEM_ID: config[CONF_SYSTEM_ID], + CONF_API_KEY: config[CONF_API_KEY], + } + ) diff --git a/homeassistant/components/pvoutput/const.py b/homeassistant/components/pvoutput/const.py new file mode 100644 index 00000000000..dd2aca530ed --- /dev/null +++ b/homeassistant/components/pvoutput/const.py @@ -0,0 +1,25 @@ +"""Constants for the PVOutput integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "pvoutput" +PLATFORMS = [Platform.SENSOR] + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=2) + + +ATTR_ENERGY_GENERATION = "energy_generation" +ATTR_POWER_GENERATION = "power_generation" +ATTR_ENERGY_CONSUMPTION = "energy_consumption" +ATTR_POWER_CONSUMPTION = "power_consumption" +ATTR_EFFICIENCY = "efficiency" + +CONF_SYSTEM_ID = "system_id" + +DEFAULT_NAME = "PVOutput" diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index d349aa33849..0afdbdeac28 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -2,6 +2,7 @@ "domain": "pvoutput", "name": "PVOutput", "documentation": "https://www.home-assistant.io/integrations/pvoutput", + "config_flow": true, "codeowners": ["@fabaff", "@frenck"], "requirements": ["pvo==0.1.0"], "iot_class": "cloud_polling" diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 8341e45579d..a108f4339e2 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -1,10 +1,7 @@ """Support for getting collected information from PVOutput.""" from __future__ import annotations -from datetime import timedelta -import logging - -from pvo import PVOutput, PVOutputError, Status +from pvo import Status import voluptuous as vol from homeassistant.components.sensor import ( @@ -13,6 +10,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, @@ -24,20 +22,22 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -_LOGGER = logging.getLogger(__name__) - -ATTR_ENERGY_GENERATION = "energy_generation" -ATTR_POWER_GENERATION = "power_generation" -ATTR_ENERGY_CONSUMPTION = "energy_consumption" -ATTR_POWER_CONSUMPTION = "power_consumption" -ATTR_EFFICIENCY = "efficiency" - -CONF_SYSTEM_ID = "system_id" - -DEFAULT_NAME = "PVOutput" - -SCAN_INTERVAL = timedelta(minutes=2) +from .const import ( + ATTR_EFFICIENCY, + ATTR_ENERGY_CONSUMPTION, + ATTR_ENERGY_GENERATION, + ATTR_POWER_CONSUMPTION, + ATTR_POWER_GENERATION, + CONF_SYSTEM_ID, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -55,51 +55,58 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the PVOutput sensor.""" - pvoutput = PVOutput( - api_key=config[CONF_API_KEY], - system_id=config[CONF_SYSTEM_ID], + LOGGER.warning( + "Configuration of the PVOutput platform in YAML is deprecated and will be " + "removed in Home Assistant 2022.4; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_SYSTEM_ID: config[CONF_SYSTEM_ID], + CONF_API_KEY: config[CONF_API_KEY], + CONF_NAME: config[CONF_NAME], + }, + ) ) - try: - status = await pvoutput.status() - except PVOutputError: - _LOGGER.error("Unable to fetch data from PVOutput") - return - async_add_entities([PvoutputSensor(pvoutput, status, config[CONF_NAME])]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Tailscale binary sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([PvoutputSensor(coordinator)]) -class PvoutputSensor(SensorEntity): +class PvoutputSensor(CoordinatorEntity, SensorEntity): """Representation of a PVOutput sensor.""" _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_device_class = SensorDeviceClass.ENERGY _attr_native_unit_of_measurement = ENERGY_WATT_HOUR - def __init__(self, pvoutput: PVOutput, status: Status, name: str) -> None: - """Initialize a PVOutput sensor.""" - self._attr_name = name - self.pvoutput = pvoutput - self.status = status + coordinator: DataUpdateCoordinator[Status] @property def native_value(self) -> int | None: """Return the state of the device.""" - return self.status.energy_generation + return self.coordinator.data.energy_generation @property def extra_state_attributes(self) -> dict[str, int | float | None]: """Return the state attributes of the monitored installation.""" return { - ATTR_ENERGY_GENERATION: self.status.energy_generation, - ATTR_POWER_GENERATION: self.status.power_generation, - ATTR_ENERGY_CONSUMPTION: self.status.energy_consumption, - ATTR_POWER_CONSUMPTION: self.status.power_consumption, - ATTR_EFFICIENCY: self.status.normalized_ouput, - ATTR_TEMPERATURE: self.status.temperature, - ATTR_VOLTAGE: self.status.voltage, + ATTR_ENERGY_GENERATION: self.coordinator.data.energy_generation, + ATTR_POWER_GENERATION: self.coordinator.data.power_generation, + ATTR_ENERGY_CONSUMPTION: self.coordinator.data.energy_consumption, + ATTR_POWER_CONSUMPTION: self.coordinator.data.power_consumption, + ATTR_EFFICIENCY: self.coordinator.data.normalized_ouput, + ATTR_TEMPERATURE: self.coordinator.data.temperature, + ATTR_VOLTAGE: self.coordinator.data.voltage, } - - async def async_update(self) -> None: - """Get the latest data from the PVOutput API and updates the state.""" - self.status = await self.pvoutput.status() diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json new file mode 100644 index 00000000000..0093181558a --- /dev/null +++ b/homeassistant/components/pvoutput/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "description": "To authenticate with PVOutput you'll need to get the API key at {account_url}.\n\nThe system IDs of registered systems are listed on that same page.", + "data": { + "system_id": "System ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/pvoutput/translations/en.json b/homeassistant/components/pvoutput/translations/en.json new file mode 100644 index 00000000000..6b34d9d2b0d --- /dev/null +++ b/homeassistant/components/pvoutput/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "system_id": "System ID" + }, + "description": "To authenticate with PVOutput you'll need to get the API key at {account_url}.\n\nThe system IDs of registered systems are listed on that same page." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b2888d7d8b4..b68b00bd264 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -244,6 +244,7 @@ FLOWS = [ "progettihwsw", "prosegur", "ps4", + "pvoutput", "pvpc_hourly_pricing", "rachio", "rainforest_eagle", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be9aa5f7e26..0b2eda93c3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,6 +789,9 @@ pure-python-adb[async]==0.3.0.dev0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 +# homeassistant.components.pvoutput +pvo==0.1.0 + # homeassistant.components.canary py-canary==0.5.1 diff --git a/tests/components/pvoutput/__init__.py b/tests/components/pvoutput/__init__.py new file mode 100644 index 00000000000..7b55b5c0471 --- /dev/null +++ b/tests/components/pvoutput/__init__.py @@ -0,0 +1 @@ +"""Tests for the PVOutput integration.""" diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py new file mode 100644 index 00000000000..ad3e215570b --- /dev/null +++ b/tests/components/pvoutput/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for PVOutput integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_API_KEY: "tskey-MOCK", CONF_SYSTEM_ID: 12345}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.pvoutput.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_pvoutput_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked PVOutput client.""" + with patch( + "homeassistant.components.pvoutput.config_flow.PVOutput", autospec=True + ) as pvoutput_mock: + yield pvoutput_mock.return_value diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py new file mode 100644 index 00000000000..a4a7392bb91 --- /dev/null +++ b/tests/components/pvoutput/test_config_flow.py @@ -0,0 +1,178 @@ +"""Tests for the PVOutput config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pvo import PVOutputAuthenticationError, PVOutputConnectionError + +from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "12345" + assert result2.get("data") == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow with incorrect API key. + + This tests tests a full config flow, with a case the user enters an invalid + PVOutput API key, but recovers by entering the correct one. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "invalid", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + mock_pvoutput_config_flow.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 2 + + +async def test_connection_error( + hass: HomeAssistant, mock_pvoutput_config_flow: MagicMock +) -> None: + """Test API connection error.""" + mock_pvoutput_config_flow.status.side_effect = PVOutputConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, +) -> None: + """Test we abort if the PVOutput system is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_SYSTEM_ID: 1337, + CONF_API_KEY: "tadaaa", + CONF_NAME: "Test", + }, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Test" + assert result.get("data") == { + CONF_SYSTEM_ID: 1337, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1