Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions homeassistant/components/axle_energy/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
61 changes: 61 additions & 0 deletions homeassistant/components/axle_energy/config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
)
9 changes: 9 additions & 0 deletions homeassistant/components/axle_energy/const.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions homeassistant/components/axle_energy/coordinator.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions homeassistant/components/axle_energy/entity.py
Original file line number Diff line number Diff line change
@@ -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,
)
11 changes: 11 additions & 0 deletions homeassistant/components/axle_energy/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
77 changes: 77 additions & 0 deletions homeassistant/components/axle_energy/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions homeassistant/components/axle_energy/sensor.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading