Add ecoforest integration (#100647)

* Add ecoforest integration

* fix file title

* remove host default from schema, hints will be given in the documentation

* moved input validation to async_step_user

* ensure we can receive device data while doing entry setup

* remove unecessary check before unique id is set

* added shorter syntax for async create entry

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* use variable to set unique id

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Use _attr_has_entity_name from base entity

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* remove unecessary comments in coordinator

* use shorthand for device information

* remove empty objects from manifest

* remove unecessary flag

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* use _async_abort_entries_match to ensure device is not duplicated

* remove unecessary host attr

* fixed coordinator host attr to be used by entities to identify device

* remove unecessary assert

* use default device class temperature trasnlation key

* reuse base entity description

* use device serial number as identifier

* remove unused code

* Improve logging message

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove unused errors

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Raise a generic update failed

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* use coordinator directly

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* No need to check for serial number

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* rename variable

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* use renamed variable

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* improve assertion

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* use serial number in entity unique id

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* raise config entry not ready on setup when error in connection

* improve test readability

* Improve python syntax

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* abort when device already configured with same serial number

* improve tests

* fix test name

* use coordinator data

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* improve asserts

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix ci

* improve error handling

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Pedro Januário
2023-09-21 14:18:55 +01:00
committed by GitHub
parent df73850f56
commit c170babba6
17 changed files with 521 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
"""Constants for the Ecoforest integration."""
from datetime import timedelta
DOMAIN = "ecoforest"
MANUFACTURER = "Ecoforest"
POLLING_INTERVAL = timedelta(seconds=30)

View File

@ -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

View File

@ -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

View File

@ -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"]
}

View File

@ -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)

View File

@ -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%]"
}
}
}

View File

@ -114,6 +114,7 @@ FLOWS = {
"eafm",
"easyenergy",
"ecobee",
"ecoforest",
"econet",
"ecowitt",
"edl21",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Ecoforest integration."""

View File

@ -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

View File

@ -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