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
73 changes: 65 additions & 8 deletions custom_components/luxtronik2/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,23 +92,80 @@ async def _set_unique_id_or_abort(

Returns None if the flow should proceed to create/update the entry.
"""
host = config[CONF_HOST]
port = config[CONF_PORT]
try:
await self.async_set_unique_id(coordinator.unique_id)
self._abort_if_unique_id_configured()
return None
except AbortFlow:
LOGGER.debug("Device already configured: %s", config[CONF_HOST])
return self.async_abort(reason="already_configured")
serial_unique_id = coordinator.unique_id
except LuxtronikSerialNumberError as err:
LOGGER.error("Could not identify device at %s: %s", config[CONF_HOST], err)
LOGGER.error("Could not identify device at %s: %s", host, err)
return self.async_abort(
reason="cannot_identify",
description_placeholders={
"host": config[CONF_HOST],
"host": host,
"error": str(err),
},
)

try:
await self.async_set_unique_id(serial_unique_id)
self._abort_if_unique_id_configured()
return None
except AbortFlow:
return self._abort_already_configured(serial_unique_id, host, port)

def _abort_already_configured(
self, serial_unique_id: str, host: str, port: int
) -> ConfigFlowResult:
"""Abort with the *reason* the device counts as already configured.

The unique ID is the heat pump's serial number, so a second endpoint
aborting here means it reported a serial that an existing entry
already uses - i.e. it is the same physical controller reached a
second way (a duplicate port forward is the usual cause, see issue
#724), not a second pump. Naming the conflicting entry turns an
otherwise unexplained "This device is already configured." into
something the user can act on.
"""
conflict = next(
(
entry
for entry in self._async_current_entries()
if entry.unique_id == serial_unique_id
),
None,
)
if conflict is None:
LOGGER.debug(
"Device at %s:%s (serial '%s') is already configured",
host,
port,
serial_unique_id,
)
return self.async_abort(reason="already_configured")

conflict_host = conflict.data.get(CONF_HOST)
conflict_port = conflict.data.get(CONF_PORT)
LOGGER.warning(
"Device at %s:%s reports serial '%s', which is already configured as "
"'%s' (%s:%s). Both addresses reach the same heat pump - check for a "
"duplicate port forward if you expected two separate units",
host,
port,
serial_unique_id,
conflict.title,
conflict_host,
conflict_port,
)
return self.async_abort(
reason="already_configured_serial",
description_placeholders={
"host": f"{host}:{port}",
"serial": serial_unique_id,
"existing_host": f"{conflict_host}:{conflict_port}",
"existing_title": conflict.title,
},
)

def _create_entry(
self, config: dict[str, Any], coordinator: LuxtronikCoordinator
) -> ConfigFlowResult:
Expand Down
1 change: 1 addition & 0 deletions custom_components/luxtronik2/translations/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@
"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.",
"already_configured_serial": "Tepeln\u00e9 \u010derpadlo na {host} hl\u00e1s\u00ed s\u00e9riov\u00e9 \u010d\u00edslo {serial}, kter\u00e9 je ji\u017e nakonfigurov\u00e1no jako {existing_title} ({existing_host}). Ob\u011b adresy vedou ke stejn\u00e9mu tepeln\u00e9mu \u010derpadlu - pokud jste o\u010dek\u00e1vali dv\u011b samostatn\u00e9 jednotky, zkontrolujte duplicitn\u00ed p\u0159esm\u011brov\u00e1n\u00ed port\u016f.",
"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.",
"already_in_progress": "Konfigura\u010dn\u00ed proces ji\u017e prob\u00edh\u00e1.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/luxtronik2/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@
"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.",
"already_configured_serial": "Die W\u00e4rmepumpe unter {host} meldet die Seriennummer {serial}, die bereits als {existing_title} ({existing_host}) konfiguriert ist. Beide Adressen erreichen dieselbe W\u00e4rmepumpe - pr\u00fcfe bei zwei erwarteten Ger\u00e4ten die Portweiterleitungen auf Duplikate.",
"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.",
"already_in_progress": "Der Konfigurationsvorgang ist bereits aktiv.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/luxtronik2/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@
"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.",
"already_configured_serial": "The heat pump at {host} reports serial number {serial}, which is already configured as {existing_title} ({existing_host}). Both addresses reach the same heat pump - if you expected two separate units, check for a duplicate port forward.",
"reconfigure_successful": "Configuration updated successfully.",
"unique_id_mismatch": "The device at the new address has a different identity than the currently configured device.",
"already_in_progress": "The configuration flow is already in progress.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/luxtronik2/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@
"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.",
"already_configured_serial": "De warmtepomp op {host} meldt serienummer {serial}, dat al is geconfigureerd als {existing_title} ({existing_host}). Beide adressen bereiken dezelfde warmtepomp - controleer de port forwarding op duplicaten als je twee afzonderlijke units verwachtte.",
"reconfigure_successful": "Configuratie succesvol bijgewerkt.",
"unique_id_mismatch": "Het apparaat op het nieuwe adres heeft een andere identiteit dan het momenteel geconfigureerde apparaat.",
"already_in_progress": "De configuratiestroom is al actief.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/luxtronik2/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@
"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.",
"already_configured_serial": "Pompa ciep\u0142a pod adresem {host} zg\u0142asza numer seryjny {serial}, kt\u00f3ry jest ju\u017c skonfigurowany jako {existing_title} ({existing_host}). Oba adresy prowadz\u0105 do tej samej pompy ciep\u0142a - je\u015bli spodziewano si\u0119 dw\u00f3ch osobnych urz\u0105dze\u0144, sprawd\u017a przekierowanie port\u00f3w pod k\u0105tem duplikat\u00f3w.",
"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.",
"already_in_progress": "Proces konfiguracji jest ju\u017c w toku.",
Expand Down
77 changes: 74 additions & 3 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,25 @@ def test_create_entry_preserves_existing_prefix(self):
# ===========================================================================


def _mock_entry(unique_id: str, host: str, port: int, title: str = "entry"):
entry = MagicMock()
entry.unique_id = unique_id
entry.title = title
entry.data = {CONF_HOST: host, CONF_PORT: port}
return entry


class TestSetUniqueIdOrAbort:
@pytest.mark.asyncio
async def test_sets_unique_id(self):
flow = LuxtronikFlowHandler()
flow.async_set_unique_id = AsyncMock()
flow._abort_if_unique_id_configured = MagicMock()
flow._async_current_entries = MagicMock(return_value=[])
coord = _mock_coordinator()
result = await flow._set_unique_id_or_abort(coord, {CONF_HOST: "1.2.3.4"})
result = await flow._set_unique_id_or_abort(
coord, {CONF_HOST: "1.2.3.4", CONF_PORT: 8889}
)
assert result is None
flow.async_set_unique_id.assert_awaited_once_with(coord.unique_id)

Expand All @@ -130,8 +141,11 @@ async def test_returns_abort_result_on_already_configured(self):
flow._abort_if_unique_id_configured = MagicMock(
side_effect=AbortFlow("already_configured")
)
flow._async_current_entries = MagicMock(return_value=[])
coord = _mock_coordinator()
result = await flow._set_unique_id_or_abort(coord, {CONF_HOST: "1.2.3.4"})
result = await flow._set_unique_id_or_abort(
coord, {CONF_HOST: "1.2.3.4", CONF_PORT: 8889}
)
assert result is not None
assert result["reason"] == "already_configured"

Expand All @@ -143,10 +157,67 @@ async def test_returns_abort_result_on_serial_number_error(self):
type(coord).unique_id = PropertyMock(
side_effect=LuxtronikSerialNumberError("no serial number")
)
result = await flow._set_unique_id_or_abort(coord, {CONF_HOST: "1.2.3.4"})
result = await flow._set_unique_id_or_abort(
coord, {CONF_HOST: "1.2.3.4", CONF_PORT: 8889}
)
assert result is not None
assert result["reason"] == "cannot_identify"

@pytest.mark.asyncio
async def test_serial_conflict_abort_names_the_existing_entry(self):
"""Issue #724: a second endpoint whose serial is already configured is
the same pump reached twice, so the abort must say which entry it
collided with instead of the bare 'already configured'."""
flow = LuxtronikFlowHandler()
flow.async_set_unique_id = AsyncMock()
flow._abort_if_unique_id_configured = MagicMock(
side_effect=AbortFlow("already_configured")
)
coord = _mock_coordinator()
flow._async_current_entries = MagicMock(
return_value=[
_mock_entry(
coord.unique_id,
"name1.mooo.com",
8452,
title="Luxtronik @ name1.mooo.com:8452",
)
]
)

result = await flow._set_unique_id_or_abort(
coord, {CONF_HOST: "name1.mooo.com", CONF_PORT: 8450}
)

assert result is not None
assert result["reason"] == "already_configured_serial"
placeholders = result["description_placeholders"]
assert placeholders["host"] == "name1.mooo.com:8450"
assert placeholders["serial"] == coord.unique_id
assert placeholders["existing_host"] == "name1.mooo.com:8452"
assert placeholders["existing_title"] == "Luxtronik @ name1.mooo.com:8452"

@pytest.mark.asyncio
async def test_abort_falls_back_when_no_entry_matches_the_serial(self):
"""The conflict can come from an in-progress flow rather than an entry;
without a matching entry there is nothing specific to report."""
flow = LuxtronikFlowHandler()
flow.async_set_unique_id = AsyncMock()
flow._abort_if_unique_id_configured = MagicMock(
side_effect=AbortFlow("already_configured")
)
flow._async_current_entries = MagicMock(
return_value=[_mock_entry("some_other_serial", "1.2.3.4", 8889)]
)
coord = _mock_coordinator()

result = await flow._set_unique_id_or_abort(
coord, {CONF_HOST: "5.6.7.8", CONF_PORT: 8889}
)

assert result is not None
assert result["reason"] == "already_configured"


# ===========================================================================
# Instance isolation (I8)
Expand Down
Loading