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
60 changes: 44 additions & 16 deletions custom_components/luxtronik2/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .coordinator import (
LuxtronikConnectionError,
LuxtronikCoordinator,
LuxtronikSerialNumberError,
connect_and_get_coordinator,
)
from .lux_helper import discover
Expand Down Expand Up @@ -75,16 +76,27 @@ async def _discover_devices(self) -> list[tuple[str, int | None]]:

async def _set_unique_id_or_abort(
self, coordinator: LuxtronikCoordinator, config: dict[str, Any]
) -> bool:
"""Set unique ID and abort if already configured."""
) -> ConfigFlowResult | None:
"""Set unique ID, returning an abort result if the flow should stop.

Returns None if the flow should proceed to create/update the entry.
"""
try:
await self.async_set_unique_id(coordinator.unique_id)
self._abort_if_unique_id_configured()

return True
return None
except AbortFlow:
LOGGER.debug("Device already configured: %s", config[CONF_HOST])
return False
return self.async_abort(reason="already_configured")
except LuxtronikSerialNumberError as err:
LOGGER.error("Could not identify device at %s: %s", config[CONF_HOST], err)
return self.async_abort(
reason="cannot_identify",
description_placeholders={
"host": config[CONF_HOST],
"error": str(err),
},
)

def _create_entry(
self, config: dict[str, Any], coordinator: LuxtronikCoordinator
Expand Down Expand Up @@ -206,8 +218,8 @@ async def async_step_select_devices(
},
)

if not await self._set_unique_id_or_abort(coordinator, config):
return self.async_abort(reason="already_configured")
if abort_result := await self._set_unique_id_or_abort(coordinator, config):
return abort_result

return self._create_entry(config, coordinator)

Expand Down Expand Up @@ -239,10 +251,10 @@ async def async_step_manual_entry(
},
)

if not await self._set_unique_id_or_abort(
if abort_result := await self._set_unique_id_or_abort( # pragma: no cover
coordinator, config
): # pragma: no cover
return self.async_abort(reason="already_configured")
):
return abort_result

return self._create_entry(config, coordinator)

Expand Down Expand Up @@ -360,7 +372,16 @@ async def async_step_dhcp(
},
)

await self.async_set_unique_id(coordinator.unique_id)
try:
await self.async_set_unique_id(coordinator.unique_id)
except LuxtronikSerialNumberError as err:
LOGGER.error(
"Could not identify DHCP-discovered device at %s: %s", host, err
)
return self.async_abort(
reason="cannot_identify",
description_placeholders={"host": host, "error": str(err)},
)
self._abort_if_unique_id_configured(
updates={CONF_HOST: host, CONF_PORT: int(port or DEFAULT_PORT)},
# Our own update listener (see __init__.py) already reloads the
Expand Down Expand Up @@ -425,12 +446,12 @@ async def async_step_reconfigure(
LOGGER.exception("Unexpected exception during reconfigure connect")
errors["base"] = "unknown"
else:
LOGGER.debug(
"Reconfigure connected and refreshed successfully; entry unique_id=%s, coordinator unique_id=%s",
reconfigure_entry.unique_id,
coordinator.unique_id,
)
try:
LOGGER.debug(
"Reconfigure connected and refreshed successfully; entry unique_id=%s, coordinator unique_id=%s",
reconfigure_entry.unique_id,
coordinator.unique_id,
)
await self.async_set_unique_id(coordinator.unique_id)
if reconfigure_entry.unique_id is None:
LOGGER.debug(
Expand All @@ -454,6 +475,13 @@ async def async_step_reconfigure(
err,
)
raise
except LuxtronikSerialNumberError as err:
LOGGER.error(
"Reconfigure could not identify device at %s: %s",
config[CONF_HOST],
err,
)
errors["base"] = "cannot_identify"
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unexpected exception during reconfigure update")
errors["base"] = "unknown"
Expand Down
71 changes: 42 additions & 29 deletions custom_components/luxtronik2/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from collections.abc import Callable, Mapping
from datetime import timedelta
from functools import wraps
import operator
import re
from types import MappingProxyType
from typing import Any, Concatenate, Final, ParamSpec, TypeVar
from typing import Any, Final

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TIMEOUT
Expand Down Expand Up @@ -53,30 +52,6 @@

# endregion Imports

_LuxtronikCoordinatorT = TypeVar("_LuxtronikCoordinatorT", bound="LuxtronikCoordinator")
_P = ParamSpec("_P")


def catch_luxtronik_errors(
func: Callable[Concatenate[_LuxtronikCoordinatorT, _P], Awaitable[None]],
) -> Callable[Concatenate[_LuxtronikCoordinatorT, _P], Coroutine[Any, Any, None]]:
"""Catch Luxtronik errors."""

@wraps(func)
async def wrapper(
self: _LuxtronikCoordinatorT,
*args: _P.args,
**kwargs: _P.kwargs,
) -> None:
"""Catch Luxtronik errors and log message."""
try:
await func(self, *args, **kwargs)
except Exception as err: # pylint: disable=broad-except
LOGGER.error("Command error: %s", err)
await self.async_request_refresh()

return wrapper


class LuxtronikCoordinator(DataUpdateCoordinator[LuxtronikCoordinatorData]):
"""Representation of a Luxtronik Coordinator."""
Expand Down Expand Up @@ -160,7 +135,7 @@ async def async_write(self, parameter: str, value: Any) -> LuxtronikCoordinatorD

return self.data
except Exception as err:
raise UpdateFailed(f"Write error: {err}") from err
raise LuxtronikWriteError(f"Write error: {err}") from err

@staticmethod
async def connect( # pragma: no cover
Expand Down Expand Up @@ -318,8 +293,21 @@ def _is_version_not_compatible(

@property
def serial_number(self) -> str:
"""Return the serial number."""
"""Return the serial number.

Raises:
LuxtronikSerialNumberError: if the serial number date (P0874) is
unavailable. This feeds `unique_id`, so it must be treated as
a hard error rather than silently identifying the device
with an empty/placeholder value.

"""
serial_number_date = self.get_value(LP.P0874_SERIAL_NUMBER)
if serial_number_date is None:
raise LuxtronikSerialNumberError(
"Serial number (P0874) is not available - coordinator data "
"may not be populated yet"
)
serial_number_model = self.get_value(LP.P0875_SERIAL_NUMBER_MODEL)
serial_number_hex = (
hex(int(serial_number_model)) if serial_number_model is not None else "0"
Expand Down Expand Up @@ -558,6 +546,8 @@ def get_sensor_by_id(self, group_sensor_id: str):

def get_sensor(self, group: str, sensor_id: str):
"""Get sensor by configured sensor ID from coordinator data."""
if self.data is None:
return None
if group == CONF_PARAMETERS:
return self.data.parameters.get(sensor_id)
if group == CONF_CALCULATIONS:
Expand Down Expand Up @@ -618,6 +608,7 @@ async def async_shutdown(self) -> None:
"""Make sure a coordinator is shut down as well as its connection."""
await super().async_shutdown()
if hasattr(self, "client") and self.client is not None:
await self.hass.async_add_executor_job(self.client.disconnect)
del self.client


Expand All @@ -633,6 +624,19 @@ def __init__(self, host: str, port: int, original: Exception):
self.original = original


class LuxtronikWriteError(HomeAssistantError):
"""Raised when writing a parameter to the Luxtronik device fails."""


class LuxtronikSerialNumberError(HomeAssistantError):
"""Raised when the heatpump's serial number cannot be determined.

The serial number feeds `unique_id`, so treating it as "not available
yet" and falling back to an empty value would risk two different
devices ending up with colliding (empty) device/entry identifiers.
"""


_OVERRIDES_APPLIED = False


Expand Down Expand Up @@ -674,6 +678,15 @@ async def connect_and_get_coordinator(
)
else:
await coordinator.async_refresh()
if not coordinator.last_update_success:
# async_refresh() (unlike async_config_entry_first_refresh())
# swallows update failures instead of raising. Without this
# check we'd hand back a coordinator with no data, and the
# failure would only surface later as a confusing error when
# something (e.g. unique_id) tries to read a sensor value.
raise RuntimeError(
f"Initial data refresh did not succeed for {host}:{port}"
)
LOGGER.debug(
"Initial coordinator refresh completed for %s:%s via direct config",
host,
Expand Down
4 changes: 4 additions & 0 deletions custom_components/luxtronik2/lux_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ def __del__(self):
"""Luxtronik helper descructor."""
self._disconnect()

def disconnect(self) -> None:
"""Explicitly close the connection to the heatpump."""
self._disconnect()

def _disconnect(self):
if self._socket is not None:
if not _is_socket_closed(self._socket):
Expand Down
2 changes: 2 additions & 0 deletions custom_components/luxtronik2/translations/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@
"config": {
"abort": {
"cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit k Luxtronik na adrese {host}.\\nChyba: {connect_error}",
"cannot_identify": "P\u0159ipojeno k za\u0159\u00edzen\u00ed na adrese {host}, ale nepoda\u0159ilo se p\u0159e\u010d\u00edst jeho s\u00e9riov\u00e9 \u010d\u00edslo pro identifikaci.\\nChyba: {error}",
"already_configured": "Toto za\u0159\u00edzen\u00ed je ji\u017e nakonfigurov\u00e1no.",
"reconfigure_successful": "Konfigurace \u00fasp\u011b\u0161n\u011b aktualizov\u00e1na.",
"unique_id_mismatch": "Za\u0159\u00edzen\u00ed na nov\u00e9 adrese m\u00e1 jinou identitu ne\u017e aktu\u00e1ln\u011b nakonfigurovan\u00e9 za\u0159\u00edzen\u00ed.",
Expand All @@ -1026,6 +1027,7 @@
"address": "Neplatn\u00e1 vzd\u00e1len\u00e1 adresa. Adresa mus\u00ed b\u00fdt IP adresa nebo rozpoznateln\u00fd n\u00e1zev hostitele.",
"address_in_use": "Zvolen\u00fd port je ji\u017e v tomto syst\u00e9mu pou\u017e\u00edv\u00e1n.",
"cannot_connect": "P\u0159ipojen\u00ed se nezda\u0159ilo. Zkontrolujte pros\u00edm host a port.",
"cannot_identify": "P\u0159ipojeno k za\u0159\u00edzen\u00ed, ale nepoda\u0159ilo se p\u0159e\u010d\u00edst jeho s\u00e9riov\u00e9 \u010d\u00edslo pro identifikaci.",
"unknown": "Neo\u010dek\u00e1van\u00e1 chyba"
},
"step": {
Expand Down
2 changes: 2 additions & 0 deletions custom_components/luxtronik2/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@
"config": {
"abort": {
"cannot_connect": "Verbindung zu Luxtronik unter {host} fehlgeschlagen.\\nFehler: {connect_error}",
"cannot_identify": "Verbindung zum Ger\u00e4t unter {host} hergestellt, aber die Seriennummer zur Identifizierung konnte nicht gelesen werden.\\nFehler: {error}",
"already_configured": "Dieses Ger\u00e4t ist bereits konfiguriert.",
"reconfigure_successful": "Konfiguration erfolgreich aktualisiert.",
"unique_id_mismatch": "Das Ger\u00e4t an der neuen Adresse hat eine andere Identit\u00e4t als das aktuell konfigurierte Ger\u00e4t.",
Expand All @@ -1021,6 +1022,7 @@
"address": "Ung\u00fcltige Remote-Adresse. Die Adresse muss eine IP-Adresse oder ein aufl\u00f6sbarer Hostname sein.",
"address_in_use": "Der gew\u00e4hlte Port wird bereits auf diesem System verwendet.",
"cannot_connect": "Verbindung fehlgeschlagen. Bitte Host und Port \u00fcberpr\u00fcfen.",
"cannot_identify": "Verbindung zum Ger\u00e4t hergestellt, aber die Seriennummer zur Identifizierung konnte nicht gelesen werden.",
"unknown": "Unerwarteter Fehler"
},
"step": {
Expand Down
2 changes: 2 additions & 0 deletions custom_components/luxtronik2/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@
"config": {
"abort": {
"cannot_connect": "Failed to connect to Luxtronik at {host}.\\nError: {connect_error}",
"cannot_identify": "Connected to the device at {host}, but could not read its serial number to identify it.\\nError: {error}",
"already_configured": "This device is already configured.",
"reconfigure_successful": "Configuration updated successfully.",
"unique_id_mismatch": "The device at the new address has a different identity than the currently configured device.",
Expand All @@ -756,6 +757,7 @@
"address": "Invalid remote-address insert. The address has to be an IP address or a resolvable hostname.",
"address_in_use": "The chosen listen port is already in use on this system.",
"cannot_connect": "Failed to connect. Please check the host and port.",
"cannot_identify": "Connected to the device, but could not read its serial number to identify it.",
"unknown": "Unexpected error"
},
"step": {
Expand Down
2 changes: 2 additions & 0 deletions custom_components/luxtronik2/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,7 @@
"config": {
"abort": {
"cannot_connect": "De verbinding met Luxtronik op {host} is mislukt.\nFout: {connect_error}",
"cannot_identify": "Verbonden met het apparaat op {host}, maar het serienummer kon niet worden gelezen om het te identificeren.\nFout: {error}",
"already_configured": "Dit apparaat is al geconfigureerd.",
"reconfigure_successful": "Configuratie succesvol bijgewerkt.",
"unique_id_mismatch": "Het apparaat op het nieuwe adres heeft een andere identiteit dan het momenteel geconfigureerde apparaat.",
Expand All @@ -1011,6 +1012,7 @@
"address": "Ongeldig extern adres. Het adres moet een IP-adres of een oplosbare hostnaam zijn.",
"address_in_use": "De gekozen poort is al in gebruik op dit systeem.",
"cannot_connect": "Verbinding mislukt. Controleer de host en poort.",
"cannot_identify": "Verbonden met het apparaat, maar het serienummer kon niet worden gelezen om het te identificeren.",
"unknown": "Onverwachte fout"
},
"step": {
Expand Down
2 changes: 2 additions & 0 deletions custom_components/luxtronik2/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,7 @@
"config": {
"abort": {
"cannot_connect": "Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107 z Luxtronik pod adresem {host}.\\nB\u0142\u0105d: {connect_error}",
"cannot_identify": "Po\u0142\u0105czono z urz\u0105dzeniem pod adresem {host}, ale nie uda\u0142o si\u0119 odczyta\u0107 jego numeru seryjnego w celu identyfikacji.\\nB\u0142\u0105d: {error}",
"already_configured": "To urz\u0105dzenie jest ju\u017c skonfigurowane.",
"reconfigure_successful": "Konfiguracja zosta\u0142a pomy\u015blnie zaktualizowana.",
"unique_id_mismatch": "Urz\u0105dzenie pod nowym adresem ma inn\u0105 to\u017csamo\u015b\u0107 ni\u017c aktualnie skonfigurowane urz\u0105dzenie.",
Expand All @@ -1039,6 +1040,7 @@
"address": "Nieprawid\u0142owy adres zdalny. Adres musi by\u0107 adresem IP lub rozpoznawaln\u0105 nazw\u0105 hosta.",
"address_in_use": "Wybrany port jest ju\u017c u\u017cywany w tym systemie.",
"cannot_connect": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119. Sprawd\u017a host i port.",
"cannot_identify": "Po\u0142\u0105czono z urz\u0105dzeniem, ale nie uda\u0142o si\u0119 odczyta\u0107 jego numeru seryjnego w celu identyfikacji.",
"unknown": "Nieoczekiwany b\u0142\u0105d"
},
"step": {
Expand Down
Loading
Loading