Add reconfiguration flow to Lyngdorf (#179411)

This commit is contained in:
Alex Fishlock
2026-08-20 14:04:50 +02:00
committed by GitHub
parent d7ca9360c1
commit 85300aeb68
4 changed files with 273 additions and 20 deletions
+101 -19
View File
@@ -4,6 +4,7 @@ import logging
from typing import Any, override
from urllib.parse import urlparse
from lyngdorf.const import LyngdorfModel
from lyngdorf.device import (
async_find_receiver_model,
async_get_device_serial,
@@ -54,31 +55,26 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._host = user_input[CONF_HOST]
try:
model = await async_find_receiver_model(self._host)
except TimeoutError:
model, serial = await self._async_probe(self._host)
except TimeoutConnect:
errors["base"] = "timeout_connect"
except OSError:
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
errors["base"] = "unknown"
if not errors and not model:
except UnsupportedModel:
errors["base"] = "unsupported_model"
if not errors and model:
except CannotDetermineId:
errors["base"] = "cannot_determine_id"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._device_model = model.model_name
self._name = model.model_name
serial = await async_get_device_serial(self._host)
if not serial:
errors["base"] = "cannot_determine_id"
else:
self._device_serial_number = serial.lower()
await self.async_set_unique_id(self._device_serial_number)
self._abort_if_unique_id_configured()
return await self._create_entry()
self._device_serial_number = serial
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
return await self._create_entry()
return self.async_show_form(
step_id="user",
@@ -90,6 +86,76 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]:
"""Return the model and serial of the device at a host."""
try:
model = await async_find_receiver_model(host)
except TimeoutError as err:
raise TimeoutConnect from err
except OSError as err:
raise CannotConnect from err
if not model:
raise UnsupportedModel
try:
serial = await async_get_device_serial(host)
except TimeoutError as err:
raise TimeoutConnect from err
except OSError as err:
raise CannotConnect from err
if not serial:
raise CannotDetermineId
return model, serial.lower()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing entry.
SSDP rediscovery only recovers a changed address while the device is
still announcing somewhere Home Assistant can hear it, which a move to
a static address or another subnet can end.
"""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
host = user_input[CONF_HOST]
try:
model, serial = await self._async_probe(host)
except TimeoutConnect:
errors["base"] = "timeout_connect"
except CannotConnect:
errors["base"] = "cannot_connect"
except UnsupportedModel:
errors["base"] = "unsupported_model"
except CannotDetermineId:
errors["base"] = "cannot_determine_id"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: host,
CONF_MODEL: model.model_name,
CONF_SERIAL_NUMBER: serial,
},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_HOST): cv.string}),
reconfigure_entry.data,
),
errors=errors,
)
@override
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
@@ -181,3 +247,19 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
raise AbortFlow("cannot_determine_id")
await self.async_set_unique_id(self._device_serial_number)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
class TimeoutConnect(Exception):
"""Error to indicate the device did not answer in time."""
class UnsupportedModel(Exception):
"""Error to indicate the device is not a model we support."""
class CannotDetermineId(Exception):
"""Error to indicate the device did not report a serial."""
@@ -74,7 +74,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repair issues needed.
@@ -5,6 +5,8 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device at this address is a different Lyngdorf device from the one this entry was set up with.",
"unsupported_model": "This Lyngdorf model is not supported"
},
"error": {
@@ -19,6 +21,16 @@
"confirm": {
"description": "Do you want to set up **{name}**?"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::lyngdorf::config::step::user::data_description::host%]"
},
"description": "Update the address Home Assistant uses to reach this device. It must be the same device; a different one will be rejected.",
"title": "[%key:component::lyngdorf::config::step::user::title%]"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -368,3 +368,162 @@ async def test_ssdp_discovery_connectivity_check_aborts(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == expected_reason
@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial")
async def test_reconfigure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguring an entry updates the host."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.50"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
@pytest.mark.usefixtures("mock_find_receiver_model")
async def test_reconfigure_different_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_get_device_serial: AsyncMock,
) -> None:
"""Test an entry cannot be pointed at a different device."""
mock_config_entry.add_to_hass(hass)
mock_get_device_serial.return_value = "aabbccddeeff"
original_host = mock_config_entry.data[CONF_HOST]
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.50"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
assert mock_config_entry.data[CONF_HOST] == original_host
@pytest.mark.parametrize(
("side_effect", "error"),
[
(TimeoutError, "timeout_connect"),
(OSError, "cannot_connect"),
(Exception, "unknown"),
],
)
@pytest.mark.usefixtures("mock_get_device_serial")
async def test_reconfigure_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_find_receiver_model: AsyncMock,
side_effect: type[Exception],
error: str,
) -> None:
"""Test reconfigure surfaces connection errors and recovers."""
mock_config_entry.add_to_hass(hass)
mock_find_receiver_model.side_effect = side_effect
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.50"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_find_receiver_model.side_effect = None
mock_find_receiver_model.return_value = LyngdorfModel.MP_60
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.50"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.parametrize(
("side_effect", "error"),
[
(TimeoutError, "timeout_connect"),
(OSError, "cannot_connect"),
],
)
@pytest.mark.usefixtures("mock_find_receiver_model")
async def test_user_flow_serial_errors(
hass: HomeAssistant,
mock_get_device_serial: AsyncMock,
side_effect: type[Exception],
error: str,
) -> None:
"""Test a failure to read the serial is surfaced on the form."""
mock_get_device_serial.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_get_device_serial.side_effect = None
mock_get_device_serial.return_value = "0050c27c76b2"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("model", "serial", "error"),
[
pytest.param(None, "0050c27c76b2", "unsupported_model", id="unsupported"),
pytest.param(LyngdorfModel.MP_60, None, "cannot_determine_id", id="no_serial"),
],
)
async def test_reconfigure_device_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_find_receiver_model: AsyncMock,
mock_get_device_serial: AsyncMock,
model: LyngdorfModel | None,
serial: str | None,
error: str,
) -> None:
"""Test reconfigure surfaces a device it cannot identify."""
mock_config_entry.add_to_hass(hass)
mock_find_receiver_model.return_value = model
mock_get_device_serial.return_value = serial
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_find_receiver_model.return_value = LyngdorfModel.MP_60
mock_get_device_serial.return_value = "0050c27c76b2"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"