diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 18dcbb5cb65..68781282d66 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.9.0"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fee2531fa20..1e1a2763b59 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData @@ -87,6 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" - if mac_address := entry.unique_id: - async_remove_scanner(hass, mac_address.upper()) + if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): + async_remove_scanner(hass, bluetooth_mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..a31f5441dbb 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -8,6 +8,7 @@ CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" +CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index c68bd560791..0903e874a15 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -25,13 +25,17 @@ async def async_get_config_entry_diagnostics( diag["config"] = config_entry.as_dict() entry_data = config_entry.runtime_data + device_info = entry_data.device_info if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data if ( - config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) + device_info + and ( + scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address + ) + and (scanner := async_scanner_by_source(hass, scanner_mac.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e32bb7d6ded..0a47fb66815 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -63,6 +63,7 @@ from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, @@ -431,6 +432,13 @@ class ESPHomeManager: device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address}, + ) # # Migrate config entry to new unique ID if the current # unique id is not a mac address. @@ -498,7 +506,9 @@ class ESPHomeManager: ) ) else: - bluetooth.async_remove_scanner(hass, device_info.mac_address) + bluetooth.async_remove_scanner( + hass, device_info.bluetooth_mac_address or device_info.mac_address + ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms @@ -617,11 +627,22 @@ class ESPHomeManager: ) _setup_services(hass, entry_data, services) - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name + if (device_info := entry_data.device_info) is not None: + if device_info.name: + reconnect_logic.name = device_info.name + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address, + }, + ) if entry.unique_id is None: hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) + entry, unique_id=format_mac(device_info.mac_address) ) await reconnect_logic.start() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b4360077604..b97878d11b5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.8.0" + "bleak-esphome==2.9.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bc33b7f17c7..8c64934cc45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4620bc21de..0a45a3c4015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 94f621b8646..2786ed8324c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -30,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -578,6 +579,19 @@ async def mock_bluetooth_entry( async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, @@ -587,6 +601,7 @@ async def mock_bluetooth_entry( }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 19bc5a2e7c7..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -13,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -21,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -33,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -41,11 +41,11 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -55,10 +55,10 @@ async def test_bluetooth_device_linked_via_device( device_registry: dr.DeviceRegistry, ) -> None: """Test the Bluetooth device is linked to the ESPHome device.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True entry = hass.config_entries.async_entry_for_domain_unique_id( - "bluetooth", "11:22:33:44:55:AA" + "bluetooth", "AA:BB:CC:DD:EE:FC" ) assert entry is not None esp_device = device_registry.async_get_device( @@ -71,7 +71,7 @@ async def test_bluetooth_device_linked_via_device( ) assert esp_device is not None device = device_registry.async_get_device( - connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} ) assert device is not None assert device.via_device_id == esp_device.id @@ -81,7 +81,7 @@ async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth is cleaned up on entry removal.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True await hass.config_entries.async_unload( mock_bluetooth_entry_with_raw_adv.entry.entry_id diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index a4b858ed7de..2d64170bc97 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_diagnostics_with_bluetooth( mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry @@ -55,9 +55,9 @@ async def test_diagnostics_with_bluetooth( "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -66,6 +66,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..ddb1babd8a4 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,6 +25,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, @@ -475,6 +476,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -1337,3 +1371,32 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC"