1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-26 22:18:40 +00:00

Fix MAC address mix-ups between WLED devices (#155491)

Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Kamil Breguła
2025-11-27 15:10:32 +01:00
committed by Franck Nijhof
parent f8d5a8bc58
commit 57835efc9d
6 changed files with 190 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ from wled import WLED, Device, WLEDConnectionError
from homeassistant.components import onboarding
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -16,6 +17,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN
@@ -52,6 +54,19 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
device.info.mac_address, raise_on_progress=False
)
if self.source == SOURCE_RECONFIGURE:
entry = self._get_reconfigure_entry()
self._abort_if_unique_id_mismatch(
reason="unique_id_mismatch",
description_placeholders={
"expected_mac": format_mac(entry.unique_id).upper(),
"actual_mac": format_mac(self.unique_id).upper(),
},
)
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
@@ -61,13 +76,26 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: user_input[CONF_HOST],
},
)
data_schema = vol.Schema({vol.Required(CONF_HOST): str})
if self.source == SOURCE_RECONFIGURE:
entry = self._get_reconfigure_entry()
data_schema = self.add_suggested_values_to_schema(
data_schema,
entry.data,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
data_schema=data_schema,
errors=errors or {},
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure flow for WLED entry."""
return await self.async_step_user(user_input)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:

View File

@@ -14,7 +14,9 @@ from wled import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -120,6 +122,16 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
translation_placeholders={"error": str(error)},
) from error
if device.info.mac_address != self.config_entry.unique_id:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="mac_address_mismatch",
translation_placeholders={
"expected_mac": format_mac(self.config_entry.unique_id).upper(),
"actual_mac": format_mac(device.info.mac_address).upper(),
},
)
# If the device supports a WebSocket, try activating it.
if (
device.info.websocket is not None

View File

@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
@@ -133,6 +135,9 @@
},
"invalid_response_wled_error": {
"message": "Invalid response from WLED API: {error}"
},
"mac_address_mismatch": {
"message": "MAC address does not match the configured device. Expected to connect to device with MAC: {expected_mac}, but connected to device with MAC: {actual_mac}."
}
},
"options": {

View File

@@ -37,6 +37,98 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
assert result["result"].unique_id == "aabbccddeeff"
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
async def test_full_reconfigure_flow_success(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
) -> None:
"""Test the full reconfigure flow from start to finish."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
# Assert show form initially
assert result.get("step_id") == "user"
assert result.get("type") is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
)
# Assert show text message and close flow
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reconfigure_successful"
# Assert config entry has been updated.
assert mock_config_entry.data[CONF_HOST] == "10.10.0.10"
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
async def test_full_reconfigure_flow_unique_id_mismatch(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
) -> None:
"""Test reconfiguration failure when the unique ID changes."""
mock_config_entry.add_to_hass(hass)
# Change mac address
device = mock_wled.update.return_value
device.info.mac_address = "invalid"
result = await mock_config_entry.start_reconfigure_flow(hass)
# Assert show form initially
assert result.get("step_id") == "user"
assert result.get("type") is FlowResultType.FORM
# Input new host value
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
)
# Assert Show text message and close flow
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "unique_id_mismatch"
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
async def test_full_reconfigure_flow_connection_error_and_success(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
) -> None:
"""Test we show user form on WLED connection error and allows user to change host."""
mock_config_entry.add_to_hass(hass)
# Mock connection error
mock_wled.update.side_effect = WLEDConnectionError
result = await mock_config_entry.start_reconfigure_flow(hass)
# Assert show form initially
assert result.get("step_id") == "user"
assert result.get("type") is FlowResultType.FORM
# Input new host value
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
)
# Assert form with errors
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
assert result.get("errors") == {"base": "cannot_connect"}
# Remove mock for connection error
mock_wled.update.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
)
# Assert show text message and close flow
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reconfigure_successful"
# Assert config entry has been updated.
assert mock_config_entry.data[CONF_HOST] == "10.10.0.10"
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None:
"""Test the full manual user flow from start to finish."""

View File

@@ -14,6 +14,7 @@ from wled import (
)
from homeassistant.components.wled.const import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
STATE_OFF,
@@ -195,3 +196,25 @@ async def test_websocket_disconnect_on_home_assistant_stop(
await hass.async_block_till_done()
await hass.async_block_till_done()
assert mock_wled.disconnect.call_count == 2
async def test_fail_when_other_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wled: MagicMock,
) -> None:
"""Ensure entry fails to setup when mac mismatch."""
device = mock_wled.update.return_value
device.info.mac_address = "invalid"
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR
assert mock_config_entry.reason
assert (
"MAC address does not match the configured device." in mock_config_entry.reason
)

View File

@@ -3,9 +3,11 @@
from datetime import datetime
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.wled.const import SCAN_INTERVAL
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ICON,
@@ -21,7 +23,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_wled")
@@ -189,3 +191,28 @@ async def test_no_current_measurement(
assert hass.states.get("sensor.wled_rgb_light_max_current") is None
assert hass.states.get("sensor.wled_rgb_light_estimated_current") is None
async def test_fail_when_other_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
mock_wled: MagicMock,
) -> None:
"""Ensure no data are updated when mac address mismatch."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (state := hass.states.get("sensor.wled_rgb_light_ip"))
assert state.state == "127.0.0.1"
device = mock_wled.update.return_value
device.info.mac_address = "invalid"
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get("sensor.wled_rgb_light_ip"))
assert state.state == "unavailable"