diff --git a/.coveragerc b/.coveragerc index ac08240fd0f..5cdb7ec1a14 100644 --- a/.coveragerc +++ b/.coveragerc @@ -258,6 +258,10 @@ omit = homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py + homeassistant/components/ecoforest/__init__.py + homeassistant/components/ecoforest/coordinator.py + homeassistant/components/ecoforest/entity.py + homeassistant/components/ecoforest/sensor.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 5bd97369ef5..4fdf8845fe9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecobee/ @marthoc @marcolivierarsenault /tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecoforest/ @pjanuario +/tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py new file mode 100644 index 00000000000..cc5575248fe --- /dev/null +++ b/homeassistant/components/ecoforest/__init__.py @@ -0,0 +1,59 @@ +"""The Ecoforest integration.""" +from __future__ import annotations + +import logging + +import httpx +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import ( + EcoforestAuthenticationRequired, + EcoforestConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ecoforest from a config entry.""" + + host = entry.data[CONF_HOST] + auth = httpx.BasicAuth(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + api = EcoforestApi(host, auth) + + try: + device = await api.get() + _LOGGER.debug("Ecoforest: %s", device) + except EcoforestAuthenticationRequired: + _LOGGER.error("Authentication on device %s failed", host) + return False + except EcoforestConnectionError as err: + _LOGGER.error("Error communicating with device %s", host) + raise ConfigEntryNotReady from err + + coordinator = EcoforestCoordinator(hass, api) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py new file mode 100644 index 00000000000..0afc46c2370 --- /dev/null +++ b/homeassistant/components/ecoforest/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Ecoforest integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from httpx import BasicAuth +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecoforest.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + api = EcoforestApi( + user_input[CONF_HOST], + BasicAuth(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]), + ) + device = await api.get() + except EcoforestAuthenticationRequired: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {device.serial_number}", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/ecoforest/const.py b/homeassistant/components/ecoforest/const.py new file mode 100644 index 00000000000..8f8bbdcb45a --- /dev/null +++ b/homeassistant/components/ecoforest/const.py @@ -0,0 +1,8 @@ +"""Constants for the Ecoforest integration.""" + +from datetime import timedelta + +DOMAIN = "ecoforest" +MANUFACTURER = "Ecoforest" + +POLLING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py new file mode 100644 index 00000000000..b44ccc850ce --- /dev/null +++ b/homeassistant/components/ecoforest/coordinator.py @@ -0,0 +1,39 @@ +"""The ecoforest coordinator.""" + + +import logging + +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestError +from pyecoforest.models.device import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EcoforestCoordinator(DataUpdateCoordinator[Device]): + """DataUpdateCoordinator to gather data from ecoforest device.""" + + def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + """Initialize DataUpdateCoordinator.""" + + super().__init__( + hass, + _LOGGER, + name="ecoforest", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> Device: + """Fetch all device and sensor data from api.""" + try: + data = await self.api.get() + _LOGGER.debug("Ecoforest data: %s", data) + return data + except EcoforestError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ecoforest/entity.py b/homeassistant/components/ecoforest/entity.py new file mode 100644 index 00000000000..901ed1bf4bf --- /dev/null +++ b/homeassistant/components/ecoforest/entity.py @@ -0,0 +1,42 @@ +"""Base Entity for Ecoforest.""" +from __future__ import annotations + +from pyecoforest.models.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import EcoforestCoordinator + + +class EcoforestEntity(CoordinatorEntity[EcoforestCoordinator]): + """Common Ecoforest entity using CoordinatorEntity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EcoforestCoordinator, + description: EntityDescription, + ) -> None: + """Initialize device information.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}_{description.key}" + + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name=MANUFACTURER, + model=coordinator.data.model_name, + sw_version=coordinator.data.firmware, + manufacturer=MANUFACTURER, + ) + + @property + def data(self) -> Device: + """Return ecoforest data.""" + assert self.coordinator.data + return self.coordinator.data diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json new file mode 100644 index 00000000000..518f4d97a04 --- /dev/null +++ b/homeassistant/components/ecoforest/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecoforest", + "name": "Ecoforest", + "codeowners": ["@pjanuario"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecoforest", + "iot_class": "local_polling", + "requirements": ["pyecoforest==0.3.0"] +} diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py new file mode 100644 index 00000000000..bba0a360375 --- /dev/null +++ b/homeassistant/components/ecoforest/sensor.py @@ -0,0 +1,72 @@ +"""Support for Ecoforest sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyecoforest.models.device import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestSensorEntityDescription( + SensorEntityDescription, EcoforestRequiredKeysMixin +): + """Describes Ecoforest sensor entity.""" + + +SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( + EcoforestSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data.environment_temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Ecoforest sensor platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EcoforestSensor(coordinator, description) for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSensor(SensorEntity, EcoforestEntity): + """Representation of an Ecoforest sensor.""" + + entity_description: EcoforestSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json new file mode 100644 index 00000000000..d6e3212b4ea --- /dev/null +++ b/homeassistant/components/ecoforest/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 3f37f3a19df..54089723e21 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ FLOWS = { "eafm", "easyenergy", "ecobee", + "ecoforest", "econet", "ecowitt", "edl21", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8c7defb6969..aac00cdd0d8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1311,6 +1311,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ecoforest": { + "name": "Ecoforest", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "econet": { "name": "Rheem EcoNet Products", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c059d20cbd5..1367844c418 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,6 +1668,9 @@ pydroid-ipcam==2.0.0 # homeassistant.components.ebox pyebox==1.1.4 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbd433aa4c0..cf76db0b1b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,6 +1244,9 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/tests/components/ecoforest/__init__.py b/tests/components/ecoforest/__init__.py new file mode 100644 index 00000000000..031cba659d2 --- /dev/null +++ b/tests/components/ecoforest/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecoforest integration.""" diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py new file mode 100644 index 00000000000..09860546c15 --- /dev/null +++ b/tests/components/ecoforest/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Ecoforest tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from pyecoforest.models.device import Alarm, Device, OperationMode, State +import pytest + +from homeassistant.components.ecoforest import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecoforest.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" + + +@pytest.fixture(name="mock_device") +def mock_device_fixture(serial_number): + """Define a mocked Ecoforest device fixture.""" + mock = Mock(spec=Device) + mock.model = "model-version" + mock.model_name = "model-name" + mock.firmware = "firmware-version" + mock.serial_number = serial_number + mock.operation_mode = OperationMode.POWER + mock.on = False + mock.state = State.OFF + mock.power = 3 + mock.temperature = 21.5 + mock.alarm = Alarm.PELLETS + mock.alarm_code = "A099" + mock.environment_temperature = 23.5 + mock.cpu_temperature = 36.1 + mock.gas_temperature = 40.2 + mock.ntc_temperature = 24.2 + return mock + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title=f"Ecoforest {serial_number}", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/ecoforest/test_config_flow.py b/tests/components/ecoforest/test_config_flow.py new file mode 100644 index 00000000000..302cbe76fa9 --- /dev/null +++ b/tests/components/ecoforest/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Ecoforest config flow.""" +from unittest.mock import AsyncMock, patch + +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import pytest + +from homeassistant import config_entries +from homeassistant.components.ecoforest.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_device, config +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "result" in result + assert result["result"].unique_id == "1234" + assert result["title"] == "Ecoforest 1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry, mock_device, config +) -> None: + """Test device already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + EcoforestAuthenticationRequired("401"), + "invalid_auth", + ), + ( + Exception("Something wrong"), + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, error: Exception, message: str, mock_device, config +) -> None: + """Test we handle failed flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyecoforest.api.EcoforestApi.get", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY