Add Axle Energy integration (#181960)

This commit is contained in:
Anthony
2026-09-15 17:52:54 +02:00
committed by GitHub
parent 533c14c70b
commit 3e2180f86b
21 changed files with 926 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
@@ -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)
@@ -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
@@ -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,
)
@@ -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"]
}
@@ -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
@@ -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)
@@ -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."
}
}
}
+1
View File
@@ -92,6 +92,7 @@ FLOWS = {
"awair",
"aws_s3",
"axis",
"axle_energy",
"azure_data_explorer",
"azure_devops",
"azure_event_hub",
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Tests for Axle Energy."""
+58
View File
@@ -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
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Event end',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Axle Energy Event end',
}),
'context': <ANY>,
'entity_id': 'sensor.axle_energy_event_end',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Event start',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Axle Energy Event start',
}),
'context': <ANY>,
'entity_id': 'sensor.axle_energy_event_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'import',
'export',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Event type',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Axle Energy Event type',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'import',
'export',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.axle_energy_event_type',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'export',
})
# ---
@@ -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()
+56
View File
@@ -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
+156
View File
@@ -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()