diff --git a/.coveragerc b/.coveragerc index c5b6181f2f2635..83555abc974e47 100644 --- a/.coveragerc +++ b/.coveragerc @@ -81,6 +81,10 @@ omit = homeassistant/components/aprilaire/climate.py homeassistant/components/aprilaire/coordinator.py homeassistant/components/aprilaire/entity.py + homeassistant/components/apsystems/__init__.py + homeassistant/components/apsystems/const.py + homeassistant/components/apsystems/coordinator.py + homeassistant/components/apsystems/sensor.py homeassistant/components/aqualogic/* homeassistant/components/aquostv/media_player.py homeassistant/components/arcam_fmj/__init__.py diff --git a/.strict-typing b/.strict-typing index 1cc40b6e91a017..98eb34d2eaaa42 100644 --- a/.strict-typing +++ b/.strict-typing @@ -84,6 +84,7 @@ homeassistant.components.api.* homeassistant.components.apple_tv.* homeassistant.components.apprise.* homeassistant.components.aprs.* +homeassistant.components.apsystems.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* homeassistant.components.aranet.* diff --git a/CODEOWNERS b/CODEOWNERS index 8b1c535d60c8aa..46476fac7c767b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -127,6 +127,8 @@ build.json @home-assistant/supervisor /tests/components/aprilaire/ @chamberlain2007 /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW +/homeassistant/components/apsystems/ @mawoka-myblock @SonnenladenGmbH +/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py new file mode 100644 index 00000000000000..10ba27e9625989 --- /dev/null +++ b/homeassistant/components/apsystems/__init__.py @@ -0,0 +1,34 @@ +"""The APsystems local API integration.""" + +from __future__ import annotations + +import logging + +from APsystemsEZ1 import APsystemsEZ1M + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ApSystemsDataCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + entry.runtime_data = {} + api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8) + coordinator = ApSystemsDataCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = {"COORDINATOR": coordinator} + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py new file mode 100644 index 00000000000000..f9df5b8cd2b049 --- /dev/null +++ b/homeassistant/components/apsystems/config_flow.py @@ -0,0 +1,51 @@ +"""The config_flow for APsystems local API integration.""" + +from aiohttp import client_exceptions +from APsystemsEZ1 import APsystemsEZ1M +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } +) + + +class APsystemsLocalAPIFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Apsystems local.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict | None = None, + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by the user.""" + _errors = {} + session = async_get_clientsession(self.hass, False) + + if user_input is not None: + try: + session = async_get_clientsession(self.hass, False) + api = APsystemsEZ1M(user_input[CONF_IP_ADDRESS], session=session) + device_info = await api.get_device_info() + await self.async_set_unique_id(device_info.deviceId) + except (TimeoutError, client_exceptions.ClientConnectionError) as exception: + LOGGER.warning(exception) + _errors["base"] = "connection_refused" + else: + return self.async_create_entry( + title="Solar", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=_errors, + ) diff --git a/homeassistant/components/apsystems/const.py b/homeassistant/components/apsystems/const.py new file mode 100644 index 00000000000000..857652aeae83d1 --- /dev/null +++ b/homeassistant/components/apsystems/const.py @@ -0,0 +1,6 @@ +"""Constants for the APsystems Local API integration.""" + +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "apsystems" diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py new file mode 100644 index 00000000000000..6488a790176a91 --- /dev/null +++ b/homeassistant/components/apsystems/coordinator.py @@ -0,0 +1,37 @@ +"""The coordinator for APsystems local API integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from APsystemsEZ1 import APsystemsEZ1M, ReturnOutputData + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class InverterNotAvailable(Exception): + """Error used when Device is offline.""" + + +class ApSystemsDataCoordinator(DataUpdateCoordinator): + """Coordinator used for all sensors.""" + + def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="APSystems Data", + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=12), + ) + self.api = api + self.always_update = True + + async def _async_update_data(self) -> ReturnOutputData: + return await self.api.get_output_data() diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json new file mode 100644 index 00000000000000..746f70548c4125 --- /dev/null +++ b/homeassistant/components/apsystems/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "apsystems", + "name": "APsystems", + "codeowners": ["@mawoka-myblock", "@SonnenladenGmbH"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/apsystems", + "homekit": {}, + "iot_class": "local_polling", + "requirements": ["apsystems-ez1==1.3.1"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py new file mode 100644 index 00000000000000..0358e7b65de786 --- /dev/null +++ b/homeassistant/components/apsystems/sensor.py @@ -0,0 +1,165 @@ +"""The read-only sensors for APsystems local API integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from APsystemsEZ1 import ReturnOutputData + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ApSystemsDataCoordinator + + +@dataclass(frozen=True, kw_only=True) +class ApsystemsLocalApiSensorDescription(SensorEntityDescription): + """Describes Apsystens Inverter sensor entity.""" + + value_fn: Callable[[ReturnOutputData], float | None] + + +SENSORS: tuple[ApsystemsLocalApiSensorDescription, ...] = ( + ApsystemsLocalApiSensorDescription( + key="total_power", + translation_key="total_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p1 + c.p2, + ), + ApsystemsLocalApiSensorDescription( + key="total_power_p1", + translation_key="total_power_p1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p1, + ), + ApsystemsLocalApiSensorDescription( + key="total_power_p2", + translation_key="total_power_p2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p2, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production", + translation_key="lifetime_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te1 + c.te2, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production_p1", + translation_key="lifetime_production_p1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te1, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production_p2", + translation_key="lifetime_production_p2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te2, + ), + ApsystemsLocalApiSensorDescription( + key="today_production", + translation_key="today_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e1 + c.e2, + ), + ApsystemsLocalApiSensorDescription( + key="today_production_p1", + translation_key="today_production_p1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e1, + ), + ApsystemsLocalApiSensorDescription( + key="today_production_p2", + translation_key="today_production_p2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the sensor platform.""" + config = config_entry.runtime_data + coordinator = config["COORDINATOR"] + device_name = config_entry.title + device_id: str = config_entry.unique_id # type: ignore[assignment] + + add_entities( + ApSystemsSensorWithDescription(coordinator, desc, device_name, device_id) + for desc in SENSORS + ) + + +class ApSystemsSensorWithDescription(CoordinatorEntity, SensorEntity): + """Base sensor to be used with description.""" + + entity_description: ApsystemsLocalApiSensorDescription + + def __init__( + self, + coordinator: ApSystemsDataCoordinator, + entity_description: ApsystemsLocalApiSensorDescription, + device_name: str, + device_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._device_name = device_name + self._device_id = device_id + self._attr_unique_id = f"{device_id}_{entity_description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Get the DeviceInfo.""" + return DeviceInfo( + identifiers={("apsystems", self._device_id)}, + name=self._device_name, + serial_number=self._device_id, + manufacturer="APsystems", + model="EZ1-M", + ) + + @callback + def _handle_coordinator_update(self) -> None: + if self.coordinator.data is None: + return # type: ignore[unreachable] + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + self.async_write_ha_state() diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json new file mode 100644 index 00000000000000..d6e3212b4eab64 --- /dev/null +++ b/homeassistant/components/apsystems/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 07041cecea6f21..1987581ff7c3a0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ "apcupsd", "apple_tv", "aprilaire", + "apsystems", "aranet", "arcam_fmj", "arve", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8f64447768bb3a..7c2f8a95de5369 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -408,6 +408,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "apsystems": { + "name": "APsystems", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "aqualogic": { "name": "AquaLogic", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 42b5581d42cf4c..6661cd78208824 100644 --- a/mypy.ini +++ b/mypy.ini @@ -601,6 +601,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apsystems.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.aqualogic.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3f708a767e3b57..0a1c8a9899e6a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -457,6 +457,9 @@ apprise==1.8.0 # homeassistant.components.aprs aprslib==0.7.2 +# homeassistant.components.apsystems +apsystems-ez1==1.3.1 + # homeassistant.components.aqualogic aqualogic==2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fefd58a823a54..bcb3484f30f528 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -421,6 +421,9 @@ apprise==1.8.0 # homeassistant.components.aprs aprslib==0.7.2 +# homeassistant.components.apsystems +apsystems-ez1==1.3.1 + # homeassistant.components.aranet aranet4==2.3.3 diff --git a/tests/components/apsystems/__init__.py b/tests/components/apsystems/__init__.py new file mode 100644 index 00000000000000..9c3c5990be04c9 --- /dev/null +++ b/tests/components/apsystems/__init__.py @@ -0,0 +1 @@ +"""Tests for the APsystems Local API integration.""" diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py new file mode 100644 index 00000000000000..72728657ef1da7 --- /dev/null +++ b/tests/components/apsystems/conftest.py @@ -0,0 +1,16 @@ +"""Common fixtures for the APsystems Local API tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.apsystems.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/apsystems/test_config_flow.py b/tests/components/apsystems/test_config_flow.py new file mode 100644 index 00000000000000..669f60c9331eb3 --- /dev/null +++ b/tests/components/apsystems/test_config_flow.py @@ -0,0 +1,97 @@ +"""Test the APsystems Local API config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.components.apsystems.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form_cannot_connect_and_recover( + hass: HomeAssistant, mock_setup_entry +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.apsystems.config_flow.APsystemsEZ1M", + return_value=AsyncMock(), + ) as mock_api: + mock_api.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.2", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "connection_refused"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.apsystems.config_flow.APsystemsEZ1M", + return_value=AsyncMock(), + ) as mock_api: + ret_data = MagicMock() + ret_data.deviceId = "MY_SERIAL_NUMBER" + mock_api.return_value.get_device_info = AsyncMock(return_value=ret_data) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result2["result"].unique_id == "MY_SERIAL_NUMBER" + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" + + +async def test_form_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.apsystems.config_flow.APsystemsEZ1M", + return_value=AsyncMock(), + ) as mock_api: + mock_api.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.2", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "connection_refused"} + + +async def test_form_create_success(hass: HomeAssistant, mock_setup_entry) -> None: + """Test we handle creatinw with success.""" + with patch( + "homeassistant.components.apsystems.config_flow.APsystemsEZ1M", + return_value=AsyncMock(), + ) as mock_api: + ret_data = MagicMock() + ret_data.deviceId = "MY_SERIAL_NUMBER" + mock_api.return_value.get_device_info = AsyncMock(return_value=ret_data) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["result"].unique_id == "MY_SERIAL_NUMBER" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result["data"].get(CONF_IP_ADDRESS) == "127.0.0.1"