diff --git a/.coveragerc b/.coveragerc index f6ecdc3e718..9c02fb7f677 100644 --- a/.coveragerc +++ b/.coveragerc @@ -331,6 +331,9 @@ omit = homeassistant/components/environment_canada/weather.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py + homeassistant/components/epion/__init__.py + homeassistant/components/epion/coordinator.py + homeassistant/components/epion/sensor.py homeassistant/components/epson/__init__.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1288ea53591..d1aa09eb93c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -359,6 +359,8 @@ build.json @home-assistant/supervisor /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 +/homeassistant/components/epion/ @lhgravendeel +/tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py new file mode 100644 index 00000000000..ed2f5559f32 --- /dev/null +++ b/homeassistant/components/epion/__init__.py @@ -0,0 +1,32 @@ +"""The Epion integration.""" +from __future__ import annotations + +from epion import Epion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import EpionCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Epion coordinator from a config entry.""" + api = Epion(entry.data[CONF_API_KEY]) + coordinator = EpionCoordinator(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 Epion config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/epion/config_flow.py b/homeassistant/components/epion/config_flow.py new file mode 100644 index 00000000000..7c89df94519 --- /dev/null +++ b/homeassistant/components/epion/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Epion.""" +from __future__ import annotations + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class EpionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Epion.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input: + api = Epion(user_input[CONF_API_KEY]) + try: + api_data = await self.hass.async_add_executor_job(api.get_current) + except EpionAuthenticationError: + errors["base"] = "invalid_auth" + except EpionConnectionError: + _LOGGER.error("Unexpected problem when configuring Epion API") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(api_data["accountId"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Epion integration", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/epion/const.py b/homeassistant/components/epion/const.py new file mode 100644 index 00000000000..83f82261583 --- /dev/null +++ b/homeassistant/components/epion/const.py @@ -0,0 +1,5 @@ +"""Constants for the Epion API.""" +from datetime import timedelta + +DOMAIN = "epion" +REFRESH_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py new file mode 100644 index 00000000000..3eb7efb5dc7 --- /dev/null +++ b/homeassistant/components/epion/coordinator.py @@ -0,0 +1,45 @@ +"""The Epion data coordinator.""" + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REFRESH_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Epion data update coordinator.""" + + def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + """Initialize the Epion coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Epion", + update_interval=REFRESH_INTERVAL, + ) + self.epion_api = epion_api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Epion API and construct a dictionary with device IDs as keys.""" + try: + response = await self.hass.async_add_executor_job( + self.epion_api.get_current + ) + except EpionAuthenticationError as err: + _LOGGER.error("Authentication error with Epion API") + raise ConfigEntryAuthFailed from err + except EpionConnectionError as err: + _LOGGER.error("Epion API connection problem") + raise UpdateFailed(f"Error communicating with API: {err}") from err + device_data = {} + for epion_device in response["devices"]: + device_data[epion_device["deviceId"]] = epion_device + return device_data diff --git a/homeassistant/components/epion/manifest.json b/homeassistant/components/epion/manifest.json new file mode 100644 index 00000000000..a1b8497f7e2 --- /dev/null +++ b/homeassistant/components/epion/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "epion", + "name": "Epion", + "codeowners": ["@lhgravendeel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/epion", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["epion"], + "requirements": ["epion==0.0.3"] +} diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py new file mode 100644 index 00000000000..826d565c2cd --- /dev/null +++ b/homeassistant/components/epion/sensor.py @@ -0,0 +1,113 @@ +"""Support for Epion API.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EpionCoordinator + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="co2", + suggested_display_precision=0, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + key="temperature", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + key="humidity", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.HPA, + key="pressure", + suggested_display_precision=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add an Epion entry.""" + coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EpionSensor(coordinator, epion_device_id, description) + for epion_device_id in coordinator.data + for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): + """Representation of an Epion Air sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EpionCoordinator, + epion_device_id: str, + description: SensorEntityDescription, + ) -> None: + """Initialize an EpionSensor.""" + super().__init__(coordinator) + self._epion_device_id = epion_device_id + self.entity_description = description + self.unique_id = f"{epion_device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._epion_device_id)}, + manufacturer="Epion", + name=self.device.get("deviceName"), + sw_version=self.device.get("fwVersion"), + model="Epion Air", + ) + + @property + def native_value(self) -> float | None: + """Return the value reported by the sensor, or None if the relevant sensor can't produce a current measurement.""" + return self.device.get(self.entity_description.key) + + @property + def available(self) -> bool: + """Return the availability of the device that provides this sensor data.""" + return super().available and self._epion_device_id in self.coordinator.data + + @property + def device(self) -> dict[str, Any]: + """Get the device record from the current coordinator data, or None if there is no data being returned for this device ID anymore.""" + return self.coordinator.data[self._epion_device_id] diff --git a/homeassistant/components/epion/strings.json b/homeassistant/components/epion/strings.json new file mode 100644 index 00000000000..f8ef9de230c --- /dev/null +++ b/homeassistant/components/epion/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c62203b4d6c..752092c02c7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -140,6 +140,7 @@ FLOWS = { "enocean", "enphase_envoy", "environment_canada", + "epion", "epson", "escea", "esphome", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 49527ba6dd0..373853681d7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1577,6 +1577,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "epion": { + "name": "Epion", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "epson": { "name": "Epson", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index 5d0fd2f36d9..5cf4603f373 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -787,6 +787,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fbc37f8323..53be88e3f4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -638,6 +638,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 diff --git a/tests/components/epion/__init__.py b/tests/components/epion/__init__.py new file mode 100644 index 00000000000..2327d2fa848 --- /dev/null +++ b/tests/components/epion/__init__.py @@ -0,0 +1 @@ +"""Tests for the Epion component.""" diff --git a/tests/components/epion/conftest.py b/tests/components/epion/conftest.py new file mode 100644 index 00000000000..2290d0d4c8f --- /dev/null +++ b/tests/components/epion/conftest.py @@ -0,0 +1,25 @@ +"""Epion tests configuration.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.common import load_json_object_fixture + + +@pytest.fixture +def mock_epion(): + """Build a fixture for the Epion API that connects successfully and returns one device.""" + current_one_device_data = load_json_object_fixture( + "epion/get_current_one_device.json" + ) + mock_epion_api = MagicMock() + with patch( + "homeassistant.components.epion.config_flow.Epion", + return_value=mock_epion_api, + ) as mock_epion_api, patch( + "homeassistant.components.epion.Epion", + return_value=mock_epion_api, + ): + mock_epion_api.return_value.get_current.return_value = current_one_device_data + yield mock_epion_api diff --git a/tests/components/epion/fixtures/get_current_one_device.json b/tests/components/epion/fixtures/get_current_one_device.json new file mode 100644 index 00000000000..4cfeb673bfe --- /dev/null +++ b/tests/components/epion/fixtures/get_current_one_device.json @@ -0,0 +1,15 @@ +{ + "devices": [ + { + "deviceId": "abc", + "deviceName": "Test Device", + "co2": 500, + "temperature": 12.34, + "humidity": 34.56, + "pressure": 1010.101, + "lastMeasurement": 1705329293171, + "fwVersion": "1.2.3" + } + ], + "accountId": "account-dupe-123" +} diff --git a/tests/components/epion/test_config_flow.py b/tests/components/epion/test_config_flow.py new file mode 100644 index 00000000000..50666d52336 --- /dev/null +++ b/tests/components/epion/test_config_flow.py @@ -0,0 +1,115 @@ +"""Tests for the Epion config flow.""" +from unittest.mock import MagicMock, patch + +from epion import EpionAuthenticationError, EpionConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.epion.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +API_KEY = "test-key-123" + + +async def test_user_flow(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test we can handle a regular successflow setup flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (EpionAuthenticationError("Invalid auth"), "invalid_auth"), + (EpionConnectionError("Timeout error"), "cannot_connect"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str, mock_epion: MagicMock +) -> None: + """Test we can handle Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_epion.return_value.get_current.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_epion.return_value.get_current.side_effect = None + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test duplicate setup handling.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + }, + unique_id="account-dupe-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0