8000 Add Leneda integration - base functionality by fedus · Pull Request #145972 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Add Leneda integration - base functionality #145972

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: dev
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions CODEOWNERS

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

53 changes: 53 additions & 0 deletions homeassistant/components/leneda/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""The Leneda integration."""

from __future__ import annotations

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry

from .const import CONF_API_TOKEN, CONF_ENERGY_ID
from .coordinator import LenedaCoordinator

PLATFORMS: list[Platform] = [Platform.SENSOR]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I missing something, it seems the sensor platform is missing (no sensor.py)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right - the sensor platform is coming up.

I am "stacking" PRs as I have the whole thing laid out locally, but will introduce the changes bit by bit. This bit unfortunately slipped through.

So, it's going to be relevant for the next PR. If you wish, I can also completely remove it from the current PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend including the basic sensor platform at least and improving on that after merging. Just in case this takes a while, and not everything is ready for 2025.7.0. At least the integration will do something, otherwise this could cause errors and problems for users.

Copy link
Author
@fedus fedus Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is what made me so torn between having one big (first) PR and several smaller ones. As it stands, the current PR will only allow users to add authentication (config entries), and not even metering points / energy meters (config subentries).
To have something "usable", the metering points stuff will need to be included as well. 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the initial PR should be the MVP, so in this case, no logic around dynamic metering points or anything, just register what is available and create something. But I'll leave that for someone from core.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now included a minimal sensor.py (it doesn't even have async_setup_entry yet because there is nothing to set up at this point)


type LenedaConfigEntry = ConfigEntry[LenedaCoordinator]

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: LenedaConfigEntry) -> bool:

"""Set up Leneda from a config entry or subentry."""
_LOGGER.debug("Setting up entry %s", entry.entry_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is already logged


api_token, energy_id = entry.data[CONF_API_TOKEN], entry.data[CONF_ENERGY_ID]
coordinator = LenedaCoordinator(hass, entry, api_token, energy_id)
Comment on lines +26 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory you're passing the entry to the coordinator so you also could have the entry.data lookups in the coordinator

entry.runtime_data = coordinator

await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

# Set up a listener for subentry changes
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
Comment on lines +33 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are no subentries now right?


return True


async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle update."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a metering point device (only relevant for subentries)."""
return True
155 changes: 155 additions & 0 deletions homeassistant/components/leneda/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Config flow for Leneda integration."""

from __future__ import annotations

from collections.abc import Mapping
import logging
from typing import Any, Final

from leneda import LenedaClient
from leneda.exceptions import ForbiddenException, UnauthorizedException
from leneda.obis_codes import ObisCode
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import callback
from homeassistant.helpers import selector

from .const import CONF_API_TOKEN, CONF_ENERGY_ID, DOMAIN

_LOGGER = logging.getLogger(__name__)

# Setup types
SETUP_TYPE_PROBE: Final = "probe"
SETUP_TYPE_MANUAL: Final = "manual"

# Error messages
ERROR_INVALID_METERING_POINT: Final = "invalid_metering_point"
ERROR_SELECT_AT_LEAST_ONE: Final = "select_at_least_one"
ERROR_DUPLICATE_METERING_POINT: Final = "duplicate_metering_point"
ERROR_FORBIDDEN: Final = "forbidden"
ERROR_UNAUTHORIZED: Final = "unauthorized"


class LenedaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class LenedaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class LenedaConfigFlow(ConfigFlow, domain=DOMAIN):

"""Handle a config flow for Leneda (main entry: authentication only)."""

VERSION = 1

def __init__(self) -> None:
"""Initialize the config flow."""
self._api_token: str = ""
self._energy_id: str = ""

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication flow when API token becomes invalid."""
self._energy_id = entry_data[CONF_ENERGY_ID]
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation step."""
errors: dict[str, str] = {}
if user_input is not None:
self._api_token = user_input[CONF_API_TOKEN]

# Validate new API token
try:
client = LenedaClient(
api_key=self._api_token,
energy_id=self._energy_id,
)
# Use a dummy metering point ID to test authentication
await client.probe_metering_point_obis_code(
"dummy-metering-point", ObisCode.ELEC_CONSUMPTION_ACTIVE
)
except UnauthorizedException:
errors = {"base": ERROR_UNAUTHORIZED}
except ForbiddenException:
errors = {"base": ERROR_FORBIDDEN}
else:
# Update the config entry with new token
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: self._api_token},
)

return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_API_TOKEN): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD,
autocomplete="leneda-api-token",
)
),
}
),
description_placeholders={"energy_id": self._energy_id},
errors=errors,
)
Comment on lines +45 to +95
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep reauth out for now so we can focus more on the rest?


async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_ENERGY_ID])
self._abort_if_unique_id_configured()
self._api_token = user_input[CONF_API_TOKEN]
self._energy_id = user_input[CONF_ENERGY_ID]

# Validate authentication by making a test API call
try:
client = LenedaClient(
api_key=self._api_token,
energy_id=self._energy_id,
)
Comment on lines +110 to +113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10000

does creating the client raise?

# Use a dummy metering point ID to test authentication
await client.probe_metering_point_obis_code(
"dummy-metering-point", ObisCode.ELEC_CONSUMPTION_ACTIVE
)
except UnauthorizedException:
errors = {"base": ERROR_UNAUTHORIZED}
except ForbiddenException:
errors = {"base": ERROR_FORBIDDEN}
else:
return self.async_create_entry(
title=self._energy_id,
data={
CONF_API_TOKEN: self._api_token,
CONF_ENERGY_ID: self._energy_id,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_TOKEN): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD,
autocomplete="leneda-api-token",
)
),
vol.Required(CONF_ENERGY_ID): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
autocomplete="leneda-energy-id",
)
),
}
),
errors=errors,
)

@staticmethod
@callback
def async_get_config_entry_title(config_entry: config_entries.ConfigEntry) -> str:
"""Get the title for the config entry."""
return config_entry.data.get(CONF_ENERGY_ID, "Leneda")
118 changes: 118 additions & 0 deletions homeassistant/components/leneda/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Constants for the Leneda integration."""

from datetime import timedelta

from leneda.obis_codes import ObisCode

from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
UnitOfReactiveEnergy,
UnitOfReactivePower,
)

DOMAIN = "leneda"

CONF_API_TOKEN = "api_token"
CONF_ENERGY_ID = "energy_id"
CONF_METERING_POINT = "metering_point"

SCAN_INTERVAL = timedelta(hours=1)

# Sensor types and their corresponding OBIS codes
SENSOR_TYPES = {
# Electricity Consumption
"electricity_consumption_active": {
"obis_code": ObisCode.ELEC_CONSUMPTION_ACTIVE,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_consumption_reactive": {
"obis_code": ObisCode.ELEC_CONSUMPTION_REACTIVE,
"device_class": SensorDeviceClass.REACTIVE_ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_consumption_covered_layer1": {
"obis_code": ObisCode.ELEC_CONSUMPTION_COVERED_LAYER1,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_consumption_covered_layer2": {
"obis_code": ObisCode.ELEC_CONSUMPTION_COVERED_LAYER2,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
F42D },
"electricity_consumption_covered_layer3": {
"obis_code": ObisCode.ELEC_CONSUMPTION_COVERED_LAYER3,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_consumption_covered_layer4": {
"obis_code": ObisCode.ELEC_CONSUMPTION_COVERED_LAYER4,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_consumption_remaining": {
"obis_code": ObisCode.ELEC_CONSUMPTION_REMAINING,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
# Electricity Production
"electricity_production_active": {
"obis_code": ObisCode.ELEC_PRODUCTION_ACTIVE,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_reactive": {
"obis_code": ObisCode.ELEC_PRODUCTION_REACTIVE,
"device_class": SensorDeviceClass.REACTIVE_ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_shared_layer1": {
"obis_code": ObisCode.ELEC_PRODUCTION_SHARED_LAYER1,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_shared_layer2": {
"obis_code": ObisCode.ELEC_PRODUCTION_SHARED_LAYER2,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_shared_layer3": {
"obis_code": ObisCode.ELEC_PRODUCTION_SHARED_LAYER3,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_shared_layer4": {
"obis_code": ObisCode.ELEC_PRODUCTION_SHARED_LAYER4,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"electricity_production_remaining": {
"obis_code": ObisCode.ELEC_PRODUCTION_REMAINING,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
# Gas Consumption
"gas_consumption_volume": {
"obis_code": ObisCode.GAS_CONSUMPTION_VOLUME,
"device_class": SensorDeviceClass.GAS,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"gas_consumption_standard_volume": {
"obis_code": ObisCode.GAS_CONSUMPTION_STANDARD_VOLUME,
"device_class": None, # Ideally SensorDeviceClass.GAS, but Nm3 not supported by Hass
"state_class": SensorStateClass.TOTAL_INCREASING,
},
"gas_consumption_energy": {
"obis_code": ObisCode.GAS_CONSUMPTION_ENERGY,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
},
}

UNIT_TO_AGGREGATED_UNIT = {
UnitOfPower.KILO_WATT.lower(): UnitOfEnergy.KILO_WATT_HOUR,
UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE.lower(): UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
}
Comment on lines +23 to +118
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not exist in const.py, and I think we need to look into using entity_descriptions

Loading
0