Skip to content

Commit 9ee7ed5

Browse files
mik-lajjoostlek
andauthored
Fix MAC address mix-ups between WLED devices (home-assistant#155491)
Co-authored-by: mik-laj <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 83c4e2a commit 9ee7ed5

File tree

6 files changed

+190
-3
lines changed

6 files changed

+190
-3
lines changed

homeassistant/components/wled/config_flow.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99

1010
from homeassistant.components import onboarding
1111
from homeassistant.config_entries import (
12+
SOURCE_RECONFIGURE,
1213
ConfigFlow,
1314
ConfigFlowResult,
1415
OptionsFlowWithReload,
1516
)
1617
from homeassistant.const import CONF_HOST, CONF_MAC
1718
from homeassistant.core import callback
1819
from homeassistant.helpers.aiohttp_client import async_get_clientsession
20+
from homeassistant.helpers.device_registry import format_mac
1921
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
2022

2123
from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN
@@ -52,6 +54,19 @@ async def async_step_user(
5254
await self.async_set_unique_id(
5355
device.info.mac_address, raise_on_progress=False
5456
)
57+
if self.source == SOURCE_RECONFIGURE:
58+
entry = self._get_reconfigure_entry()
59+
self._abort_if_unique_id_mismatch(
60+
reason="unique_id_mismatch",
61+
description_placeholders={
62+
"expected_mac": format_mac(entry.unique_id).upper(),
63+
"actual_mac": format_mac(self.unique_id).upper(),
64+
},
65+
)
66+
return self.async_update_reload_and_abort(
67+
entry,
68+
data_updates=user_input,
69+
)
5570
self._abort_if_unique_id_configured(
5671
updates={CONF_HOST: user_input[CONF_HOST]}
5772
)
@@ -61,13 +76,26 @@ async def async_step_user(
6176
CONF_HOST: user_input[CONF_HOST],
6277
},
6378
)
79+
data_schema = vol.Schema({vol.Required(CONF_HOST): str})
80+
if self.source == SOURCE_RECONFIGURE:
81+
entry = self._get_reconfigure_entry()
82+
data_schema = self.add_suggested_values_to_schema(
83+
data_schema,
84+
entry.data,
85+
)
6486

6587
return self.async_show_form(
6688
step_id="user",
67-
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
89+
data_schema=data_schema,
6890
errors=errors or {},
6991
)
7092

93+
async def async_step_reconfigure(
94+
self, user_input: dict[str, Any] | None = None
95+
) -> ConfigFlowResult:
96+
"""Handle reconfigure flow for WLED entry."""
97+
return await self.async_step_user(user_input)
98+
7199
async def async_step_zeroconf(
72100
self, discovery_info: ZeroconfServiceInfo
73101
) -> ConfigFlowResult:

homeassistant/components/wled/coordinator.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from homeassistant.config_entries import ConfigEntry
1515
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
1616
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
17+
from homeassistant.exceptions import ConfigEntryError
1718
from homeassistant.helpers.aiohttp_client import async_get_clientsession
19+
from homeassistant.helpers.device_registry import format_mac
1820
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1921

2022
from .const import (
@@ -120,6 +122,16 @@ async def _async_update_data(self) -> WLEDDevice:
120122
translation_placeholders={"error": str(error)},
121123
) from error
122124

125+
if device.info.mac_address != self.config_entry.unique_id:
126+
raise ConfigEntryError(
127+
translation_domain=DOMAIN,
128+
translation_key="mac_address_mismatch",
129+
translation_placeholders={
130+
"expected_mac": format_mac(self.config_entry.unique_id).upper(),
131+
"actual_mac": format_mac(device.info.mac_address).upper(),
132+
},
133+
)
134+
123135
# If the device supports a WebSocket, try activating it.
124136
if (
125137
device.info.websocket is not None

homeassistant/components/wled/strings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
"config": {
33
"abort": {
44
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
5-
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
5+
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
6+
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
7+
"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."
68
},
79
"error": {
810
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
@@ -133,6 +135,9 @@
133135
},
134136
"invalid_response_wled_error": {
135137
"message": "Invalid response from WLED API: {error}"
138+
},
139+
"mac_address_mismatch": {
140+
"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}."
136141
}
137142
},
138143
"options": {

tests/components/wled/test_config_flow.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,98 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
3737
assert result["result"].unique_id == "aabbccddeeff"
3838

3939

40+
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
41+
async def test_full_reconfigure_flow_success(
42+
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
43+
) -> None:
44+
"""Test the full reconfigure flow from start to finish."""
45+
mock_config_entry.add_to_hass(hass)
46+
47+
result = await mock_config_entry.start_reconfigure_flow(hass)
48+
49+
# Assert show form initially
50+
assert result.get("step_id") == "user"
51+
assert result.get("type") is FlowResultType.FORM
52+
result = await hass.config_entries.flow.async_configure(
53+
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
54+
)
55+
56+
# Assert show text message and close flow
57+
assert result.get("type") is FlowResultType.ABORT
58+
assert result.get("reason") == "reconfigure_successful"
59+
60+
# Assert config entry has been updated.
61+
assert mock_config_entry.data[CONF_HOST] == "10.10.0.10"
62+
63+
64+
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
65+
async def test_full_reconfigure_flow_unique_id_mismatch(
66+
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
67+
) -> None:
68+
"""Test reconfiguration failure when the unique ID changes."""
69+
mock_config_entry.add_to_hass(hass)
70+
71+
# Change mac address
72+
device = mock_wled.update.return_value
73+
device.info.mac_address = "invalid"
74+
75+
result = await mock_config_entry.start_reconfigure_flow(hass)
76+
77+
# Assert show form initially
78+
assert result.get("step_id") == "user"
79+
assert result.get("type") is FlowResultType.FORM
80+
81+
# Input new host value
82+
result = await hass.config_entries.flow.async_configure(
83+
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
84+
)
85+
86+
# Assert Show text message and close flow
87+
assert result.get("type") is FlowResultType.ABORT
88+
assert result.get("reason") == "unique_id_mismatch"
89+
90+
91+
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
92+
async def test_full_reconfigure_flow_connection_error_and_success(
93+
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock
94+
) -> None:
95+
"""Test we show user form on WLED connection error and allows user to change host."""
96+
mock_config_entry.add_to_hass(hass)
97+
98+
# Mock connection error
99+
mock_wled.update.side_effect = WLEDConnectionError
100+
101+
result = await mock_config_entry.start_reconfigure_flow(hass)
102+
103+
# Assert show form initially
104+
assert result.get("step_id") == "user"
105+
assert result.get("type") is FlowResultType.FORM
106+
107+
# Input new host value
108+
result = await hass.config_entries.flow.async_configure(
109+
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
110+
)
111+
112+
# Assert form with errors
113+
assert result.get("type") is FlowResultType.FORM
114+
assert result.get("step_id") == "user"
115+
assert result.get("errors") == {"base": "cannot_connect"}
116+
117+
# Remove mock for connection error
118+
mock_wled.update.side_effect = None
119+
120+
result = await hass.config_entries.flow.async_configure(
121+
result["flow_id"], user_input={CONF_HOST: "10.10.0.10"}
122+
)
123+
124+
# Assert show text message and close flow
125+
assert result.get("type") is FlowResultType.ABORT
126+
assert result.get("reason") == "reconfigure_successful"
127+
128+
# Assert config entry has been updated.
129+
assert mock_config_entry.data[CONF_HOST] == "10.10.0.10"
130+
131+
40132
@pytest.mark.usefixtures("mock_setup_entry", "mock_wled")
41133
async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None:
42134
"""Test the full manual user flow from start to finish."""

tests/components/wled/test_coordinator.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515

1616
from homeassistant.components.wled.const import SCAN_INTERVAL
17+
from homeassistant.config_entries import ConfigEntryState
1718
from homeassistant.const import (
1819
EVENT_HOMEASSISTANT_STOP,
1920
STATE_OFF,
@@ -195,3 +196,25 @@ async def connect(callback: Callable[[WLEDDevice], None]):
195196
await hass.async_block_till_done()
196197
await hass.async_block_till_done()
197198
assert mock_wled.disconnect.call_count == 2
199+
200+
201+
async def test_fail_when_other_device(
202+
hass: HomeAssistant,
203+
mock_config_entry: MockConfigEntry,
204+
mock_wled: MagicMock,
205+
) -> None:
206+
"""Ensure entry fails to setup when mac mismatch."""
207+
device = mock_wled.update.return_value
208+
device.info.mac_address = "invalid"
209+
210+
mock_config_entry.add_to_hass(hass)
211+
212+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
213+
214+
await hass.async_block_till_done()
215+
216+
assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR
217+
assert mock_config_entry.reason
218+
assert (
219+
"MAC address does not match the configured device." in mock_config_entry.reason
220+
)

tests/components/wled/test_sensor.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from datetime import datetime
44
from unittest.mock import MagicMock, patch
55

6+
from freezegun.api import FrozenDateTimeFactory
67
import pytest
78

89
from homeassistant.components.sensor import SensorDeviceClass
10+
from homeassistant.components.wled.const import SCAN_INTERVAL
911
from homeassistant.const import (
1012
ATTR_DEVICE_CLASS,
1113
ATTR_ICON,
@@ -21,7 +23,7 @@
2123
from homeassistant.helpers import entity_registry as er
2224
from homeassistant.util import dt as dt_util
2325

24-
from tests.common import MockConfigEntry
26+
from tests.common import MockConfigEntry, async_fire_time_changed
2527

2628

2729
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_wled")
@@ -189,3 +191,28 @@ async def test_no_current_measurement(
189191

190192
assert hass.states.get("sensor.wled_rgb_light_max_current") is None
191193
assert hass.states.get("sensor.wled_rgb_light_estimated_current") is None
194+
195+
196+
async def test_fail_when_other_device(
197+
hass: HomeAssistant,
198+
mock_config_entry: MockConfigEntry,
199+
freezer: FrozenDateTimeFactory,
200+
mock_wled: MagicMock,
201+
) -> None:
202+
"""Ensure no data are updated when mac address mismatch."""
203+
mock_config_entry.add_to_hass(hass)
204+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
205+
await hass.async_block_till_done()
206+
207+
assert (state := hass.states.get("sensor.wled_rgb_light_ip"))
208+
assert state.state == "127.0.0.1"
209+
210+
device = mock_wled.update.return_value
211+
device.info.mac_address = "invalid"
212+
213+
freezer.tick(SCAN_INTERVAL)
214+
async_fire_time_changed(hass)
215+
await hass.async_block_till_done()
216+
217+
assert (state := hass.states.get("sensor.wled_rgb_light_ip"))
218+
assert state.state == "unavailable"

0 commit comments

Comments
 (0)