diff --git a/CODEOWNERS b/CODEOWNERS index 779f6f8b6c8f3..aa0c0d105d28a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -418,6 +418,8 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt +/homeassistant/components/egauge/ @neggert +/tests/components/egauge/ @neggert /homeassistant/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd /homeassistant/components/ekeybionyx/ @richardpolzer diff --git a/homeassistant/components/egauge/__init__.py b/homeassistant/components/egauge/__init__.py new file mode 100644 index 0000000000000..3cbc19ca51e4c --- /dev/null +++ b/homeassistant/components/egauge/__init__.py @@ -0,0 +1,42 @@ +"""Integration for eGauge energy monitors.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN, MANUFACTURER, MODEL +from .coordinator import EgaugeConfigEntry, EgaugeDataCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: EgaugeConfigEntry) -> bool: + """Set up eGauge from a config entry.""" + + coordinator = EgaugeDataCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + # Store coordinator in runtime_data + entry.runtime_data = coordinator + + # Set up main device + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.serial_number)}, + name=coordinator.hostname, + manufacturer=MANUFACTURER, + model=MODEL, + serial_number=coordinator.serial_number, + ) + + # Setup sensor platform + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EgaugeConfigEntry) -> bool: + """Unload eGauge config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/egauge/config_flow.py b/homeassistant/components/egauge/config_flow.py new file mode 100644 index 0000000000000..8d0a8c935dc24 --- /dev/null +++ b/homeassistant/components/egauge/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow to configure the eGauge integration.""" + +from __future__ import annotations + +from typing import Any + +from egauge_async.exceptions import EgaugeAuthenticationError, EgaugePermissionError +from egauge_async.json.client import EgaugeJsonClient +from httpx import ConnectError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=True): bool, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } +) + + +class EgaugeFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle an eGauge config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + client = EgaugeJsonClient( + host=user_input[CONF_HOST], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + client=get_async_client( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ), + use_ssl=user_input[CONF_SSL], + ) + try: + serial_number = await client.get_device_serial_number() + hostname = await client.get_hostname() + except EgaugeAuthenticationError: + errors["base"] = "invalid_auth" + except EgaugePermissionError: + errors["base"] = "missing_permission" + except ConnectError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=hostname, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/egauge/const.py b/homeassistant/components/egauge/const.py new file mode 100644 index 0000000000000..f5ba494da707d --- /dev/null +++ b/homeassistant/components/egauge/const.py @@ -0,0 +1,10 @@ +"""Constants for the eGauge integration.""" + +import logging + +DOMAIN = "egauge" +LOGGER = logging.getLogger(__package__) + +MANUFACTURER = "eGauge Systems" +MODEL = "eGauge Energy Monitor" +COORDINATOR_UPDATE_INTERVAL_SECONDS = 30 diff --git a/homeassistant/components/egauge/coordinator.py b/homeassistant/components/egauge/coordinator.py new file mode 100644 index 0000000000000..2791d828e6d33 --- /dev/null +++ b/homeassistant/components/egauge/coordinator.py @@ -0,0 +1,105 @@ +"""Data update coordinator for eGauge energy monitors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from egauge_async.exceptions import ( + EgaugeAuthenticationError, + EgaugeException, + EgaugePermissionError, +) +from egauge_async.json.client import EgaugeJsonClient +from egauge_async.json.models import RegisterInfo +from httpx import ConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import COORDINATOR_UPDATE_INTERVAL_SECONDS, DOMAIN, LOGGER + +type EgaugeConfigEntry = ConfigEntry[EgaugeDataCoordinator] + + +@dataclass +class EgaugeData: + """Data from eGauge device.""" + + measurements: dict[str, float] # Instantaneous values (W, V, A, etc.) + counters: dict[str, float] # Cumulative values (Ws) + register_info: dict[str, RegisterInfo] # Metadata for all registers + + +class EgaugeDataCoordinator(DataUpdateCoordinator[EgaugeData]): + """Class to manage fetching eGauge data.""" + + serial_number: str + hostname: str + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=COORDINATOR_UPDATE_INTERVAL_SECONDS), + config_entry=config_entry, + ) + self.client = EgaugeJsonClient( + host=config_entry.data[CONF_HOST], + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + client=get_async_client( + hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL] + ), + use_ssl=config_entry.data[CONF_SSL], + ) + # Populated in _async_setup + self._register_info: dict[str, RegisterInfo] = {} + + async def _async_setup(self) -> None: + try: + self.serial_number = await self.client.get_device_serial_number() + self.hostname = await self.client.get_hostname() + self._register_info = await self.client.get_register_info() + except ( + EgaugeAuthenticationError, + EgaugePermissionError, + EgaugeException, + ) as err: + # EgaugeAuthenticationError and EgaugePermissionError will raise ConfigEntryAuthFailed once reauth is implemented + raise ConfigEntryError from err + except ConnectError as err: + raise UpdateFailed(f"Error fetching device info: {err}") from err + + async def _async_update_data(self) -> EgaugeData: + """Fetch data from eGauge device.""" + try: + measurements = await self.client.get_current_measurements() + counters = await self.client.get_current_counters() + except ( + EgaugeAuthenticationError, + EgaugePermissionError, + EgaugeException, + ) as err: + # will raise ConfigEntryAuthFailed once reauth is implemented + raise ConfigEntryError("Error fetching device info: {err}") from err + except ConnectError as err: + raise UpdateFailed(f"Error fetching device info: {err}") from err + + return EgaugeData( + measurements=measurements, + counters=counters, + register_info=self._register_info, + ) diff --git a/homeassistant/components/egauge/entity.py b/homeassistant/components/egauge/entity.py new file mode 100644 index 0000000000000..3db1fa9ba9a91 --- /dev/null +++ b/homeassistant/components/egauge/entity.py @@ -0,0 +1,35 @@ +"""Base entity for the eGauge integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, MODEL +from .coordinator import EgaugeDataCoordinator + + +class EgaugeEntity(CoordinatorEntity[EgaugeDataCoordinator]): + """Base entity for eGauge sensors.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EgaugeDataCoordinator, + register_name: str, + ) -> None: + """Initialize the eGauge entity.""" + super().__init__(coordinator) + + register_identifier = f"{coordinator.serial_number}_{register_name}" + register_name = f"{coordinator.hostname} {register_name}" + + # Device info using coordinator's cached data + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, register_identifier)}, + name=register_name, + manufacturer=MANUFACTURER, + model=MODEL, + via_device=(DOMAIN, coordinator.serial_number), + ) diff --git a/homeassistant/components/egauge/manifest.json b/homeassistant/components/egauge/manifest.json new file mode 100644 index 0000000000000..51252003a882a --- /dev/null +++ b/homeassistant/components/egauge/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "egauge", + "name": "eGauge", + "codeowners": ["@neggert"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/egauge", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["egauge-async==0.4.0"] +} diff --git a/homeassistant/components/egauge/quality_scale.yaml b/homeassistant/components/egauge/quality_scale.yaml new file mode 100644 index 0000000000000..bae5c2e162b47 --- /dev/null +++ b/homeassistant/components/egauge/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not expose configuration options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: Integration only has essential entities + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: Integration uses standard device class icons + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/egauge/sensor.py b/homeassistant/components/egauge/sensor.py new file mode 100644 index 0000000000000..f5cd776ca3549 --- /dev/null +++ b/homeassistant/components/egauge/sensor.py @@ -0,0 +1,99 @@ +"""Sensor platform for eGauge energy monitors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from egauge_async.json.models import RegisterType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EgaugeConfigEntry, EgaugeData, EgaugeDataCoordinator +from .entity import EgaugeEntity + + +@dataclass(frozen=True, kw_only=True) +class EgaugeSensorEntityDescription(SensorEntityDescription): + """Extended sensor description for eGauge sensors.""" + + native_value_fn: Callable[[EgaugeData, str], float] + available_fn: Callable[[EgaugeData, str], bool] + + +SENSORS: tuple[EgaugeSensorEntityDescription, ...] = ( + EgaugeSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + native_value_fn=lambda data, register: data.measurements[register], + available_fn=lambda data, register: register in data.measurements, + ), + EgaugeSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.JOULE, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_value_fn=lambda data, register: data.counters[register], + available_fn=lambda data, register: register in data.counters, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EgaugeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up eGauge sensor platform.""" + coordinator = entry.runtime_data + async_add_entities( + EgaugeSensor(coordinator, register_name, sensor) + for sensor in SENSORS + for register_name, register_info in coordinator.data.register_info.items() + if register_info.type == RegisterType.POWER + ) + + +class EgaugeSensor(EgaugeEntity, SensorEntity): + """Generic sensor entity using entity description pattern.""" + + entity_description: EgaugeSensorEntityDescription + + def __init__( + self, + coordinator: EgaugeDataCoordinator, + register_name: str, + description: EgaugeSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, register_name) + self._register_name = register_name + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.serial_number}_{register_name}_{description.key}" + ) + + @property + def native_value(self) -> float: + """Return the sensor value using the description's value function.""" + return self.entity_description.native_value_fn( + self.coordinator.data, self._register_name + ) + + @property + def available(self) -> bool: + """Return true if the corresponding register is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data, self._register_name + ) diff --git a/homeassistant/components/egauge/strings.json b/homeassistant/components/egauge/strings.json new file mode 100644 index 0000000000000..6844f84694a11 --- /dev/null +++ b/homeassistant/components/egauge/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_permission": "The provided user does not have the necessary permissions", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the eGauge device", + "password": "The password for the provided user.", + "ssl": "Use SSL for a secure connection.", + "username": "The username for the eGauge device. The user must have permission to read registers and settings.", + "verify_ssl": "Verify SSL certificate. eGauge devices use a self-signed certificate by default, so leave this off unless a custom certificate has been installed on the device." + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 725feeb1d4d64..a3688a97bb7b7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -173,6 +173,7 @@ "ecowitt", "edl21", "efergy", + "egauge", "eheimdigital", "ekeybionyx", "electrasmart", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3b9fd26326642..a1c226c846728 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1591,6 +1591,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "egauge": { + "name": "eGauge", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "eheimdigital": { "name": "EHEIM Digital", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 68d9d637392f9..9b82f86a6ca9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -850,6 +850,9 @@ ebusdpy==0.0.17 # homeassistant.components.ecoal_boiler ecoaliface==0.4.0 +# homeassistant.components.egauge +egauge-async==0.4.0 + # homeassistant.components.eheimdigital eheimdigital==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3085435d37d9..9ca27041a7741 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -750,6 +750,9 @@ eagle100==0.1.1 # homeassistant.components.easyenergy easyenergy==2.1.2 +# homeassistant.components.egauge +egauge-async==0.4.0 + # homeassistant.components.eheimdigital eheimdigital==1.4.0 diff --git a/tests/components/egauge/__init__.py b/tests/components/egauge/__init__.py new file mode 100644 index 0000000000000..9a3ccc5719d71 --- /dev/null +++ b/tests/components/egauge/__init__.py @@ -0,0 +1 @@ +"""Tests for the eGauge integration.""" diff --git a/tests/components/egauge/conftest.py b/tests/components/egauge/conftest.py new file mode 100644 index 0000000000000..5a65ca2c68118 --- /dev/null +++ b/tests/components/egauge/conftest.py @@ -0,0 +1,95 @@ +"""Fixtures for eGauge integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from egauge_async.json.models import RegisterInfo, RegisterType +import pytest + +from homeassistant.components.egauge.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="eGauge", + domain=DOMAIN, + data={ + CONF_HOST: "http://192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, + unique_id="ABC123456", + ) + + +@pytest.fixture(autouse=True) +def mock_egauge_client() -> Generator[MagicMock]: + """Return a mocked eGauge client.""" + with ( + patch( + "homeassistant.components.egauge.coordinator.EgaugeJsonClient", + autospec=True, + ) as mock_class, + patch( + "homeassistant.components.egauge.config_flow.EgaugeJsonClient", + new=mock_class, + ), + ): + client = mock_class.return_value + + # Static device info + client.get_device_serial_number.return_value = "ABC123456" + client.get_hostname.return_value = "egauge-home" + client.get_register_info.return_value = { + "Grid": RegisterInfo(name="Grid", type=RegisterType.POWER, idx=0, did=None), + "Solar": RegisterInfo( + name="Solar", type=RegisterType.POWER, idx=1, did=None + ), + # Include unsupported type to test graceful handling + "Temp": RegisterInfo( + name="Temp", type=RegisterType.TEMPERATURE, idx=2, did=None + ), + } + + # Dynamic measurements + client.get_current_measurements.return_value = { + "Grid": 1500.0, + "Solar": -2500.0, + "Temp": 45.0, + } + client.get_current_counters.return_value = { + "Grid": 450000000.0, # 125 kWh in Ws + "Solar": 315000000.0, # 87.5 kWh in Ws + "Temp": 0.0, + } + + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_egauge_client: MagicMock, +) -> MockConfigEntry: + """Set up the eGauge integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/egauge/snapshots/test_sensor.ambr b/tests/components/egauge/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..22b4be6e9166b --- /dev/null +++ b/tests/components/egauge/snapshots/test_sensor.ambr @@ -0,0 +1,262 @@ +# serializer version: 1 +# name: test_sensors.8 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'egauge', + 'ABC123456', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'eGauge Systems', + 'model': 'eGauge Energy Monitor', + 'model_id': None, + 'name': 'egauge-home', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'ABC123456', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.egauge_home_grid_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.egauge_home_grid_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'egauge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123456_Grid_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.egauge_home_grid_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'egauge-home Grid Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.egauge_home_grid_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '125.0', + }) +# --- +# name: test_sensors[sensor.egauge_home_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.egauge_home_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'egauge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123456_Grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.egauge_home_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'egauge-home Grid Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.egauge_home_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_sensors[sensor.egauge_home_solar_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.egauge_home_solar_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'egauge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123456_Solar_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.egauge_home_solar_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'egauge-home Solar Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.egauge_home_solar_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.5', + }) +# --- +# name: test_sensors[sensor.egauge_home_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.egauge_home_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'egauge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123456_Solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.egauge_home_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'egauge-home Solar Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.egauge_home_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2500.0', + }) +# --- diff --git a/tests/components/egauge/test_config_flow.py b/tests/components/egauge/test_config_flow.py new file mode 100644 index 0000000000000..7c2b094afeab6 --- /dev/null +++ b/tests/components/egauge/test_config_flow.py @@ -0,0 +1,134 @@ +"""Tests for the eGauge config flow.""" + +from unittest.mock import MagicMock + +from egauge_async.exceptions import EgaugeAuthenticationError, EgaugePermissionError +from httpx import ConnectError +import pytest + +from homeassistant.components.egauge.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full happy path user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "egauge-home" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + } + assert result["result"].unique_id == "ABC123456" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (EgaugeAuthenticationError, "invalid_auth"), + (EgaugePermissionError, "missing_permission"), + (ConnectError("Connection error"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_egauge_client: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test user flow with various errors.""" + mock_egauge_client.get_device_serial_number.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "wrong", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # Test recovery after error + mock_egauge_client.get_device_serial_number.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "egauge-home" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + } + assert result["result"].unique_id == "ABC123456" + + +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "http://192.168.1.200", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/egauge/test_init.py b/tests/components/egauge/test_init.py new file mode 100644 index 0000000000000..8682350f062bc --- /dev/null +++ b/tests/components/egauge/test_init.py @@ -0,0 +1,78 @@ +"""Tests for the eGauge integration.""" + +from unittest.mock import MagicMock + +from egauge_async.exceptions import ( + EgaugeAuthenticationError, + EgaugeParsingException, + EgaugePermissionError, +) +from httpx import ConnectError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_egauge_client: MagicMock, +) -> None: + """Test successful setup.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_egauge_client.get_device_serial_number.called + assert mock_egauge_client.get_hostname.called + assert mock_egauge_client.get_register_info.called + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + (ConnectError, ConfigEntryState.SETUP_RETRY), + (EgaugeAuthenticationError, ConfigEntryState.SETUP_ERROR), + (EgaugePermissionError, ConfigEntryState.SETUP_ERROR), + (EgaugeParsingException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_egauge_client: MagicMock, + exception: Exception, + expected: ConfigEntryState, +) -> None: + """Test setup with connection error.""" + mock_config_entry.add_to_hass(hass) + mock_egauge_client.get_device_serial_number.side_effect = exception + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_egauge_client: MagicMock, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/egauge/test_sensor.py b/tests/components/egauge/test_sensor.py new file mode 100644 index 0000000000000..ba412e69c03c4 --- /dev/null +++ b/tests/components/egauge/test_sensor.py @@ -0,0 +1,145 @@ +"""Tests for the eGauge sensor platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from egauge_async.exceptions import EgaugeAuthenticationError +from freezegun.api import FrozenDateTimeFactory +from httpx import ConnectError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.egauge.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Verify main device created with hostname + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "ABC123456")}) + assert device_entry + assert device_entry == snapshot + + +@pytest.mark.parametrize( + "exception", [EgaugeAuthenticationError, ConnectError("Connection failed")] +) +@pytest.mark.freeze_time("2025-01-15T10:00:00+00:00") +async def test_sensor_error( + hass: HomeAssistant, + mock_egauge_client: MagicMock, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test errors that occur after setup are handled.""" + + # Trigger exception on next update + mock_egauge_client.get_current_measurements.side_effect = exception + + # Trigger update + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Test Grid power sensor + state = hass.states.get("sensor.egauge_home_grid_power") + assert state + assert state.state == STATE_UNAVAILABLE + + # Test Grid energy sensor + state = hass.states.get("sensor.egauge_home_grid_energy") + assert state + assert state.state == STATE_UNAVAILABLE + + # Clear exception + mock_egauge_client.get_current_measurements.side_effect = None + + # Trigger update + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Test Grid power sensor is available + state = hass.states.get("sensor.egauge_home_grid_power") + assert state + assert state.state == "1500.0" + + # Test Grid energy sensor is available + state = hass.states.get("sensor.egauge_home_grid_energy") + assert state + assert state.state == "125.0" + + +@pytest.mark.freeze_time("2025-01-15T10:00:00+00:00") +async def test_register_removed( + hass: HomeAssistant, + mock_egauge_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test case where a register is removed on the eGauge device.""" + + # Remove "Grid" register + original_measurements = await mock_egauge_client.get_current_measurements() + original_counters = await mock_egauge_client.get_current_counters() + new_measurements = {k: v for k, v in original_measurements.items() if k != "Grid"} + new_counters = {k: v for k, v in original_counters.items() if k != "Grid"} + mock_egauge_client.get_current_measurements.return_value = new_measurements + mock_egauge_client.get_current_counters.return_value = new_counters + + # Trigger update + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Test Grid power sensor + state = hass.states.get("sensor.egauge_home_grid_power") + assert state + assert state.state == STATE_UNAVAILABLE + + # Test Grid energy sensor + state = hass.states.get("sensor.egauge_home_grid_energy") + assert state + assert state.state == STATE_UNAVAILABLE + + # Test that other sensors still work + state = hass.states.get("sensor.egauge_home_solar_power") + assert state + assert state.state == "-2500.0" + + state = hass.states.get("sensor.egauge_home_solar_energy") + assert state + assert state.state == "87.5" + + # Restore "Grid" register + mock_egauge_client.get_current_measurements.return_value = original_measurements + mock_egauge_client.get_current_counters.return_value = original_counters + + # Trigger update + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Test Grid power sensor is available + state = hass.states.get("sensor.egauge_home_grid_power") + assert state + assert state.state == "1500.0" + + # Test Grid energy sensor is available + state = hass.states.get("sensor.egauge_home_grid_energy") + assert state + assert state.state == "125.0"