diff --git a/.strict-typing b/.strict-typing index bba276789901..8b1f044aee5e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -109,6 +109,7 @@ homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* +homeassistant.components.axle_energy.* homeassistant.components.azure_storage.* homeassistant.components.backblaze_b2.* homeassistant.components.backup.* diff --git a/CODEOWNERS b/CODEOWNERS index 9d7731e81de7..b1bef3c79244 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,8 @@ CLAUDE.md @home-assistant/core /tests/components/aws_s3/ @tomasbedrich /homeassistant/components/axis/ @Kane610 /tests/components/axis/ @Kane610 +/homeassistant/components/axle_energy/ @Herbertmt978 +/tests/components/axle_energy/ @Herbertmt978 /homeassistant/components/azure_data_explorer/ @kaareseras /tests/components/azure_data_explorer/ @kaareseras /homeassistant/components/azure_devops/ @timmo001 diff --git a/homeassistant/components/axle_energy/__init__.py b/homeassistant/components/axle_energy/__init__.py new file mode 100644 index 000000000000..766bb47d293f --- /dev/null +++ b/homeassistant/components/axle_energy/__init__.py @@ -0,0 +1,26 @@ +"""The Axle Energy integration.""" + +from aioaxlevpp import AxleClient + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import AxleConfigEntry, AxleCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: AxleConfigEntry) -> bool: + """Set up Axle Energy from a config entry.""" + coordinator = AxleCoordinator( + hass, entry, AxleClient(async_get_clientsession(hass), entry.data[CONF_API_KEY]) + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AxleConfigEntry) -> bool: + """Unload Axle Energy.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/axle_energy/config_flow.py b/homeassistant/components/axle_energy/config_flow.py new file mode 100644 index 000000000000..2d2772f385af --- /dev/null +++ b/homeassistant/components/axle_energy/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Axle Energy.""" + +from typing import Any, override + +from aioaxlevpp import AxleAuthenticationError, AxleClient, AxleError +import probatio + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +STEP_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } +) + + +class AxleConfigFlow(ConfigFlow, domain=DOMAIN): + """Configure the household's Axle event feed.""" + + async def _validate(self, user_input: dict[str, Any]) -> dict[str, str]: + """Check a token with the service, including when no event is scheduled.""" + client = AxleClient( + async_get_clientsession(self.hass), user_input[CONF_API_KEY] + ) + try: + await client.get_event() + except AxleAuthenticationError: + return {"base": "invalid_auth"} + except AxleError: + return {"base": "cannot_connect"} + return {} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure the household event feed.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + if not (errors := await self._validate(user_input)): + return self.async_create_entry(title="Axle Energy", data=user_input) + return self.async_show_form( + step_id="user", + data_schema=STEP_SCHEMA, + description_placeholders={ + "token_url": "https://vpp.axle.energy/app/account/home-assistant" + }, + errors=errors, + ) diff --git a/homeassistant/components/axle_energy/const.py b/homeassistant/components/axle_energy/const.py new file mode 100644 index 000000000000..5d48f78da3dd --- /dev/null +++ b/homeassistant/components/axle_energy/const.py @@ -0,0 +1,9 @@ +"""Constants for Axle Energy.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "axle_energy" +PLATFORMS = [Platform.SENSOR] +UPDATE_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/axle_energy/coordinator.py b/homeassistant/components/axle_energy/coordinator.py new file mode 100644 index 000000000000..23093eaa82e0 --- /dev/null +++ b/homeassistant/components/axle_energy/coordinator.py @@ -0,0 +1,51 @@ +"""Coordinate Axle event updates.""" + +import logging +from typing import override + +from aioaxlevpp import AxleAuthenticationError, AxleClient, AxleError, GridEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) +type AxleConfigEntry = ConfigEntry[AxleCoordinator] + + +class AxleCoordinator(DataUpdateCoordinator[GridEvent | None]): + """Fetch one event using the provider's documented polling interval.""" + + config_entry: AxleConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: AxleConfigEntry, client: AxleClient + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + always_update=False, + ) + self.client = client + + @override + async def _async_update_data(self) -> GridEvent | None: + """Fetch the event without confusing outages with an empty schedule.""" + try: + event = await self.client.get_event() + except AxleAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except AxleError as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + return None if event is not None and event.opted_out else event diff --git a/homeassistant/components/axle_energy/entity.py b/homeassistant/components/axle_energy/entity.py new file mode 100644 index 000000000000..a922d199d74f --- /dev/null +++ b/homeassistant/components/axle_energy/entity.py @@ -0,0 +1,23 @@ +"""Shared entity for the Axle event feed.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AxleCoordinator + + +class AxleEntity(CoordinatorEntity[AxleCoordinator]): + """An entity belonging to one Axle event feed.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AxleCoordinator) -> None: + """Initialize the shared service identity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Axle Energy", + manufacturer="Axle Energy", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/axle_energy/manifest.json b/homeassistant/components/axle_energy/manifest.json new file mode 100644 index 000000000000..0f180300da7f --- /dev/null +++ b/homeassistant/components/axle_energy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "axle_energy", + "name": "Axle Energy", + "codeowners": ["@Herbertmt978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/axle_energy", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aioaxlevpp==0.1.0"] +} diff --git a/homeassistant/components/axle_energy/quality_scale.yaml b/homeassistant/components/axle_energy/quality_scale.yaml new file mode 100644 index 000000000000..d6a494436373 --- /dev/null +++ b/homeassistant/components/axle_energy/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + action-setup: + status: exempt + comment: This 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: This integration does not provide custom actions. + docs-triggers: + status: exempt + comment: This integration does not provide custom triggers. + docs-conditions: + status: exempt + comment: This integration does not provide custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + action-exceptions: + status: exempt + comment: This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + devices: done + diagnostics: todo + discovery: + status: exempt + comment: The household event feed is a cloud service with no local endpoint. + discovery-update-info: + status: exempt + comment: The household event feed is a cloud service with no local endpoint. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The API exposes one household event feed, not a list of devices. + entity-category: + status: exempt + comment: All entities provide primary event information. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: The API exposes one household event feed, not a list of devices. + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/axle_energy/sensor.py b/homeassistant/components/axle_energy/sensor.py new file mode 100644 index 000000000000..df9b8a4bc7e7 --- /dev/null +++ b/homeassistant/components/axle_energy/sensor.py @@ -0,0 +1,84 @@ +"""Sensor platform for Axle Energy.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import override + +from aioaxlevpp import GridEvent + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AxleConfigEntry, AxleCoordinator +from .entity import AxleEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AxleSensorDescription(SensorEntityDescription): + """Describe one field in a grid event.""" + + value_fn: Callable[[GridEvent], str | datetime] + + +SENSORS = ( + AxleSensorDescription( + key="import_export", + translation_key="import_export", + device_class=SensorDeviceClass.ENUM, + options=["import", "export"], + value_fn=lambda event: event.direction, + ), + AxleSensorDescription( + key="start", + translation_key="start", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda event: event.start, + ), + AxleSensorDescription( + key="end", + translation_key="end", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda event: event.end, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AxleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up event metadata sensors.""" + async_add_entities( + AxleSensor(entry.runtime_data, description) for description in SENSORS + ) + + +class AxleSensor(AxleEntity, SensorEntity): + """Represent a field in the current grid event.""" + + entity_description: AxleSensorDescription + + def __init__( + self, coordinator: AxleCoordinator, description: AxleSensorDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + @override + def native_value(self) -> str | datetime | None: + """Return unknown when a healthy feed has no participating event.""" + if (event := self.coordinator.data) is None: + return None + return self.entity_description.value_fn(event) diff --git a/homeassistant/components/axle_energy/strings.json b/homeassistant/components/axle_energy/strings.json new file mode 100644 index 000000000000..79e5dd84542d --- /dev/null +++ b/homeassistant/components/axle_energy/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Home Assistant token for your Axle account." + }, + "description": "Open the [Home Assistant page in your Axle account]({token_url}) and select **Generate Token**. Enter the generated token in **API key**." + } + } + }, + "entity": { + "sensor": { + "end": { + "name": "Event end" + }, + "import_export": { + "name": "Event type", + "state": { + "export": "Export", + "import": "Import" + } + }, + "start": { + "name": "Event start" + } + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed. Check your Axle API key." + }, + "cannot_connect": { + "message": "Unable to retrieve grid events from Axle." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bd717e766b7f..f5a0279fd660 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -92,6 +92,7 @@ FLOWS = { "awair", "aws_s3", "axis", + "axle_energy", "azure_data_explorer", "azure_devops", "azure_event_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6071ed15de23..1acbd8dae166 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -669,6 +669,12 @@ "config_flow": true, "iot_class": "local_push" }, + "axle_energy": { + "name": "Axle Energy", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "backblaze_b2": { "name": "Backblaze B2", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 9f25acf32c2a..b25989bc0da8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -847,6 +847,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.axle_energy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.azure_storage.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 9b0c2eb4d5c2..c6345b733955 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -217,6 +217,9 @@ aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==3.0.0 +# homeassistant.components.axle_energy +aioaxlevpp==0.1.0 + # homeassistant.components.azure_devops aioazuredevops==2.2.2 diff --git a/tests/components/axle_energy/__init__.py b/tests/components/axle_energy/__init__.py new file mode 100644 index 000000000000..f499573e36b0 --- /dev/null +++ b/tests/components/axle_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for Axle Energy.""" diff --git a/tests/components/axle_energy/conftest.py b/tests/components/axle_energy/conftest.py new file mode 100644 index 000000000000..c39389e2ef2b --- /dev/null +++ b/tests/components/axle_energy/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for Axle Energy.""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from aioaxlevpp import GridEvent +import pytest + +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Iterator[AsyncMock]: + """Skip integration setup during config flow tests.""" + with patch( + "homeassistant.components.axle_energy.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_event() -> GridEvent: + """A synthetic future export event.""" + return GridEvent( + start=datetime(2026, 9, 11, 17, tzinfo=UTC), + end=datetime(2026, 9, 11, 18, tzinfo=UTC), + direction="export", + updated_at=datetime(2026, 9, 11, 8, tzinfo=UTC), + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """One Axle household.""" + return MockConfigEntry( + domain="axle_energy", + title="Axle Energy", + entry_id="test-entry", + data={CONF_API_KEY: "test-token"}, + ) + + +@pytest.fixture(autouse=True) +def mock_client(mock_event: GridEvent) -> Iterator[AsyncMock]: + """Mock the external dependency at the integration boundary.""" + with ( + patch( + "homeassistant.components.axle_energy.AxleClient", autospec=True + ) as client, + patch( + "homeassistant.components.axle_energy.config_flow.AxleClient", new=client + ), + ): + client.return_value.get_event.return_value = mock_event + yield client.return_value diff --git a/tests/components/axle_energy/snapshots/test_sensor.ambr b/tests/components/axle_energy/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..1d744a744242 --- /dev/null +++ b/tests/components/axle_energy/snapshots/test_sensor.ambr @@ -0,0 +1,163 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.axle_energy_event_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event end', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event end', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'end', + 'unique_id': 'test-entry_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Axle Energy Event end', + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-09-11T18:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event start', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event start', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'test-entry_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Axle Energy Event start', + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-09-11T17:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'import', + 'export', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event type', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'import_export', + 'unique_id': 'test-entry_import_export', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Axle Energy Event type', + : list([ + 'import', + 'export', + ]), + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'export', + }) +# --- diff --git a/tests/components/axle_energy/test_config_flow.py b/tests/components/axle_energy/test_config_flow.py new file mode 100644 index 000000000000..1faf6ecdf08e --- /dev/null +++ b/tests/components/axle_energy/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Axle configuration flow.""" + +from unittest.mock import AsyncMock + +from aioaxlevpp import AxleAuthenticationError, AxleConnectionError, AxleError +import pytest + +from homeassistant.components.axle_energy.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user(hass: HomeAssistant) -> None: + """Configure the feed through the UI.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "test-token"} + assert result["title"] == "Axle Energy" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (AxleAuthenticationError(), "invalid_auth"), + (AxleConnectionError(), "cannot_connect"), + (AxleError(), "cannot_connect"), + ], +) +async def test_user_errors( + hass: HomeAssistant, mock_client: AsyncMock, error: Exception, message: str +) -> None: + """Show recoverable setup failures.""" + mock_client.get_event.side_effect = error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": message} + mock_client.get_event.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "replacement-token"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "replacement-token"} + assert result["title"] == "Axle Energy" + + +async def test_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: AsyncMock +) -> None: + """Reject the same key without making another API request.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_client.get_event.assert_not_called() diff --git a/tests/components/axle_energy/test_init.py b/tests/components/axle_energy/test_init.py new file mode 100644 index 000000000000..b1af5b37ed00 --- /dev/null +++ b/tests/components/axle_energy/test_init.py @@ -0,0 +1,56 @@ +"""Test integration setup and unloading.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioaxlevpp import AxleAuthenticationError, AxleConnectionError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Unload the entry and its entities.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert 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 + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called() + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (AxleAuthenticationError(), ConfigEntryState.SETUP_ERROR), + (AxleConnectionError(), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + error: Exception, + expected: ConfigEntryState, +) -> None: + """Handle authentication and retryable connection failures.""" + mock_client.get_event.side_effect = error + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is expected diff --git a/tests/components/axle_energy/test_sensor.py b/tests/components/axle_energy/test_sensor.py new file mode 100644 index 000000000000..fbc1a3de7193 --- /dev/null +++ b/tests/components/axle_energy/test_sensor.py @@ -0,0 +1,156 @@ +"""Test event metadata and coordinator lifecycle.""" + +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioaxlevpp import ( + AxleAuthenticationError, + AxleConnectionError, + AxleError, + GridEvent, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Load the integration.""" + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the registered sensors and their state attributes.""" + await setup(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "value", "key"), + [ + ("sensor.axle_energy_event_type", "export", "import_export"), + ("sensor.axle_energy_event_start", "2026-09-11T17:00:00+00:00", "start"), + ("sensor.axle_energy_event_end", "2026-09-11T18:00:00+00:00", "end"), + ], +) +async def test_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + entity_id: str, + value: str, + key: str, +) -> None: + """Expose the three event inputs with persistent identities.""" + await setup(hass, mock_config_entry) + assert hass.states.get(entity_id).state == value + registered = entity_registry.async_get(entity_id) + assert registered is not None + assert registered.config_entry_id == mock_config_entry.entry_id + assert registered.unique_id == f"{mock_config_entry.entry_id}_{key}" + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 3 + ) + + +async def test_no_event( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: AsyncMock +) -> None: + """An empty schedule is unknown, not a failed connection.""" + mock_client.get_event.return_value = None + await setup(hass, mock_config_entry) + assert hass.states.get("sensor.axle_energy_event_type").state == "unknown" + + +async def test_opted_out( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + mock_event: GridEvent, +) -> None: + """Exclude an event the household has opted out of.""" + mock_client.get_event.return_value = replace(mock_event, opted_out=True) + await setup(hass, mock_config_entry) + assert hass.states.get("sensor.axle_energy_event_type").state == "unknown" + + +@pytest.mark.parametrize("error", [AxleConnectionError(), AxleError()]) +async def test_recovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, + error: Exception, +) -> None: + """Recover from a failed request without confusing it with no event.""" + await setup(hass, mock_config_entry) + mock_client.get_event.side_effect = error + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.side_effect = None + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "export" + + +async def test_polling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + mock_event: GridEvent, + freezer: FrozenDateTimeFactory, +) -> None: + """Poll at ten minutes and publish revised direction.""" + await setup(hass, mock_config_entry) + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called() + mock_client.get_event.return_value = replace(mock_event, direction="import") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_awaited_once() + assert hass.states.get("sensor.axle_energy_event_type").state == "import" + + +async def test_authentication_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Stop polling after the service rejects the credentials.""" + await setup(hass, mock_config_entry) + mock_client.get_event.side_effect = AxleAuthenticationError() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called()