diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 6c4b462d0680..57e0821ccff3 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -1,6 +1,7 @@ """The Midea integration.""" from collections.abc import Mapping +from functools import partial from typing import Any from midealocal.const import ProtocolVersion @@ -11,6 +12,7 @@ from midealocal.discover import discover from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PORT, @@ -21,8 +23,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import CONF_KEY, CONF_SUBTYPE, LOGGER +from .const import CONF_KEY, CONF_SN, CONF_SUBTYPE, LOGGER from .entity import MideaConfigEntry _PLATFORMS: list[Platform] = [ @@ -133,6 +136,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> boo return True +async def async_migrate_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: + """Migrate an old config entry.""" + if entry.minor_version < 2: + # minor_version 2 adds mac/serial number; discovery already parses both + # from the device reply, so backfill them for pre-existing entries. + discovered = await hass.async_add_executor_job( + partial(discover, ip_address=entry.data[CONF_IP_ADDRESS]) + ) + device = discovered.get(entry.data[CONF_DEVICE_ID], {}) + new_data = {**entry.data} + if mac := device.get(CONF_MAC): + new_data[CONF_MAC] = mac + if serial_number := device.get(CONF_SN): + new_data[CONF_SN] = serial_number + + # Drop any network-mac connection with an empty value so only a real + # address remains; the device registry never removes these on its own. + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + connections = { + connection + for connection in device_entry.connections + if connection[0] != dr.CONNECTION_NETWORK_MAC + or str(connection[1]).lower() not in ("", "none") + } + if connections != device_entry.connections: + device_registry.async_update_device( + device_entry.id, new_connections=connections + ) + + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/midea/config_flow.py b/homeassistant/components/midea/config_flow.py index 154524d38dd7..45fb9b06c2bb 100644 --- a/homeassistant/components/midea/config_flow.py +++ b/homeassistant/components/midea/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PASSWORD, @@ -32,7 +33,15 @@ from homeassistant.const import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from .const import CONF_ACCOUNT, CONF_KEY, CONF_SERVER, CONF_SUBTYPE, DOMAIN, LOGGER +from .const import ( + CONF_ACCOUNT, + CONF_KEY, + CONF_SERVER, + CONF_SN, + CONF_SUBTYPE, + DOMAIN, + LOGGER, +) from .device_catalog import MIDEA_DEVICE_NAMES DEFAULT_CLOUD: str = get_default_cloud() @@ -91,7 +100,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): """ VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """MideaConfigFlow class.""" @@ -456,6 +465,8 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IP_ADDRESS: device.get(CONF_IP_ADDRESS), CONF_PORT: device.get(CONF_PORT), CONF_MODEL: device.get(CONF_MODEL), + CONF_MAC: device.get(CONF_MAC), + CONF_SN: device.get(CONF_SN), } # MUST get a auth passed token/key for v3 device, disable add before pass @@ -541,6 +552,8 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SUBTYPE: self.found_device.get(CONF_SUBTYPE) or 0, CONF_TOKEN: self.found_device.get(CONF_TOKEN) or "", CONF_KEY: self.found_device.get(CONF_KEY) or "", + CONF_MAC: self.found_device.get(CONF_MAC), + CONF_SN: self.found_device.get(CONF_SN), } async def _async_create_midea_entry( @@ -589,6 +602,10 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_TOKEN: user_input[CONF_TOKEN], CONF_KEY: user_input[CONF_KEY], } + if mac := user_input.get(CONF_MAC): + data[CONF_MAC] = mac + if serial_number := user_input.get(CONF_SN): + data[CONF_SN] = serial_number return self.async_create_entry( title=name, @@ -679,6 +696,9 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_KEY] = keys["key"] user_input[CONF_TOKEN] = keys["token"] + user_input[CONF_MAC] = device.get(CONF_MAC) + user_input[CONF_SN] = device.get(CONF_SN) + self.found_device = { CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], CONF_NAME: self.found_device.get(CONF_NAME), @@ -689,6 +709,8 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_MODEL: user_input[CONF_MODEL], CONF_TOKEN: user_input[CONF_TOKEN], CONF_KEY: user_input[CONF_KEY], + CONF_MAC: user_input[CONF_MAC], + CONF_SN: user_input[CONF_SN], } return await self._async_create_midea_entry(user_input) diff --git a/homeassistant/components/midea/const.py b/homeassistant/components/midea/const.py index 3db444cd41ae..9d2cd5a439d9 100644 --- a/homeassistant/components/midea/const.py +++ b/homeassistant/components/midea/const.py @@ -12,6 +12,7 @@ CONF_KEY = "key" CONF_SUBTYPE = "subtype" CONF_ACCOUNT = "account" CONF_SERVER = "server" +CONF_SN = "sn" PRESET_MODE_NONE = "none" diff --git a/homeassistant/components/midea/diagnostics.py b/homeassistant/components/midea/diagnostics.py index 35e6041f9087..1f805ab14b21 100644 --- a/homeassistant/components/midea/diagnostics.py +++ b/homeassistant/components/midea/diagnostics.py @@ -3,13 +3,13 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_MAC, CONF_TOKEN from homeassistant.core import HomeAssistant -from .const import CONF_KEY +from .const import CONF_KEY, CONF_SN from .entity import MideaConfigEntry -TO_REDACT = {CONF_TOKEN, CONF_KEY} +TO_REDACT = {CONF_TOKEN, CONF_KEY, CONF_MAC, CONF_SN} async def async_get_config_entry_diagnostics( diff --git a/tests/components/midea/conftest.py b/tests/components/midea/conftest.py index 5316401235cb..c47b75e3f5d4 100644 --- a/tests/components/midea/conftest.py +++ b/tests/components/midea/conftest.py @@ -174,6 +174,7 @@ def mock_config_entry() -> Callable[[DummyDevice], MockConfigEntry]: def _create(device: DummyDevice) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, + minor_version=2, data={ **BASE_DATA, CONF_TYPE: device.device_type, diff --git a/tests/components/midea/const.py b/tests/components/midea/const.py index 9b527275ca89..1b79090df57f 100644 --- a/tests/components/midea/const.py +++ b/tests/components/midea/const.py @@ -2,11 +2,12 @@ from midealocal.const import ProtocolVersion -from homeassistant.components.midea.const import CONF_KEY, CONF_SUBTYPE +from homeassistant.components.midea.const import CONF_KEY, CONF_SN, CONF_SUBTYPE from homeassistant.components.midea.device_catalog import MIDEA_DEVICE_NAMES from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_PORT, CONF_PROTOCOL, @@ -38,6 +39,8 @@ DISCOVERY_RESULT = { TEST_DEVICE_ID: { **BASE_DATA, CONF_TYPE: TEST_TYPE, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, } } diff --git a/tests/components/midea/snapshots/test_diagnostics.ambr b/tests/components/midea/snapshots/test_diagnostics.ambr index b381ae53f3aa..d3c0fb464e23 100644 --- a/tests/components/midea/snapshots/test_diagnostics.ambr +++ b/tests/components/midea/snapshots/test_diagnostics.ambr @@ -29,7 +29,7 @@ 'discovery_keys': dict({ }), 'domain': 'midea', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/midea/test_config_flow.py b/tests/components/midea/test_config_flow.py index c1468e76643a..b0fa9a5ef3fe 100644 --- a/tests/components/midea/test_config_flow.py +++ b/tests/components/midea/test_config_flow.py @@ -17,6 +17,7 @@ from homeassistant.components.midea.const import ( CONF_ACCOUNT, CONF_KEY, CONF_SERVER, + CONF_SN, CONF_SUBTYPE, DOMAIN, ) @@ -26,6 +27,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PASSWORD, @@ -44,9 +46,11 @@ from .const import ( TEST_DEVICE_ID, TEST_IP_ADDRESS, TEST_KEY, + TEST_MAC_ADDRESS, TEST_MODEL, TEST_PORT, TEST_PROTOCOL, + TEST_SERIAL_NUMBER, TEST_SUBTYPE, TEST_TOKEN, TEST_TYPE, @@ -104,6 +108,8 @@ async def test_manual_flow_success(hass: HomeAssistant) -> None: CONF_SUBTYPE: TEST_SUBTYPE, CONF_TOKEN: TEST_TOKEN, CONF_KEY: TEST_KEY, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, } @@ -1056,7 +1062,13 @@ async def test_auto_flow_v1_v2_success_when_cloud_down( ) -> None: """Test v1/v2 devices are added without ever using the cloud, even if it is down.""" mock_devices = { - TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE, CONF_PROTOCOL: protocol}, + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_PROTOCOL: protocol, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + }, } result = await hass.config_entries.flow.async_init( @@ -1105,6 +1117,8 @@ async def test_auto_flow_v1_v2_success_when_cloud_down( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID assert result["data"][CONF_PROTOCOL] == protocol + assert result["data"][CONF_MAC] == TEST_MAC_ADDRESS + assert result["data"][CONF_SN] == TEST_SERIAL_NUMBER async def test_login_credentials_step_renders_with_cloud_servers( diff --git a/tests/components/midea/test_init.py b/tests/components/midea/test_init.py index 794a84800f83..f524aab80da2 100644 --- a/tests/components/midea/test_init.py +++ b/tests/components/midea/test_init.py @@ -4,11 +4,12 @@ from unittest.mock import patch from midealocal.const import DeviceType, ProtocolVersion -from homeassistant.components.midea.const import CONF_KEY, CONF_SUBTYPE, DOMAIN +from homeassistant.components.midea.const import CONF_KEY, CONF_SN, CONF_SUBTYPE, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PORT, @@ -17,10 +18,11 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import DummyDevice -from .const import TEST_DEVICE_ID +from .const import TEST_DEVICE_ID, TEST_IP_ADDRESS, TEST_MAC_ADDRESS, TEST_SERIAL_NUMBER from tests.common import MockConfigEntry @@ -45,7 +47,7 @@ async def test_async_setup(hass: HomeAssistant) -> None: async def test_unload_entry(hass: HomeAssistant) -> None: """Test async_unload_entry unloads platforms and closes the device.""" - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) device = DummyDevice(DeviceType.AC) with patch( @@ -62,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_async_setup_entry_paths(hass: HomeAssistant) -> None: """Test async_setup_entry for success and no-device return.""" - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) with patch( "homeassistant.components.midea.device_selector", @@ -74,6 +76,7 @@ async def test_async_setup_entry_paths(hass: HomeAssistant) -> None: entry2 = MockConfigEntry( domain=DOMAIN, data={**_ENTRY_DATA, CONF_DEVICE_ID: TEST_DEVICE_ID + 1}, + minor_version=2, ) entry2.add_to_hass(hass) with patch( @@ -94,7 +97,7 @@ async def test_setup_entry_not_ready_on_connect_failure( It can also leave the socket open in that case (e.g. when authentication fails), so the socket must be closed explicitly to avoid a ResourceWarning. """ - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) device = DummyDevice(DeviceType.AC) with ( @@ -164,3 +167,94 @@ async def test_setup_entry_no_recovery_when_device_not_discovered( await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY assert entry.data[CONF_IP_ADDRESS] == _ENTRY_DATA[CONF_IP_ADDRESS] + + +async def test_migrate_entry_backfills_mac_and_serial_number( + hass: HomeAssistant, +) -> None: + """Test migration to minor_version 2 backfills mac and serial number.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.midea.discover", + return_value={ + TEST_DEVICE_ID: { + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + } + }, + ), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert entry.data[CONF_MAC] == TEST_MAC_ADDRESS + assert entry.data[CONF_SN] == TEST_SERIAL_NUMBER + + +async def test_migrate_entry_drops_empty_mac_connection( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test migration removes a leftover empty network-mac connection.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(TEST_DEVICE_ID))}, + connections={ + (dr.CONNECTION_NETWORK_MAC, "None"), + (dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS), + }, + ) + + with ( + patch( + "homeassistant.components.midea.discover", + return_value={ + TEST_DEVICE_ID: { + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + } + }, + ), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, str(TEST_DEVICE_ID)), entry.entry_id + ) + assert device_entry is not None + assert device_entry.connections == {(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)} + + +async def test_migrate_entry_without_discovery_result(hass: HomeAssistant) -> None: + """Test migration still completes when discovery finds nothing.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.midea.discover", return_value={}), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert CONF_MAC not in entry.data + assert CONF_SN not in entry.data