Set via_device for remote Bluetooth adapters to link to the parent device (#137091)

This commit is contained in:
J. Nick Koston 2025-02-01 12:10:59 -06:00 committed by Paulus Schoutsen
parent e76ff0a0de
commit 2d1d9bbe5a
11 changed files with 101 additions and 28 deletions

View File

@ -80,6 +80,7 @@ from .const import (
CONF_DETAILS, CONF_DETAILS,
CONF_PASSIVE, CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -297,7 +298,12 @@ async def async_discover_adapters(
async def async_update_device( async def async_update_device(
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails hass: HomeAssistant,
entry: ConfigEntry,
adapter: str,
details: AdapterDetails,
via_device_domain: str | None = None,
via_device_id: str | None = None,
) -> None: ) -> None:
"""Update device registry entry. """Update device registry entry.
@ -306,7 +312,8 @@ async def async_update_device(
update the device with the new location so they can update the device with the new location so they can
figure out where the adapter is. figure out where the adapter is.
""" """
dr.async_get(hass).async_get_or_create( device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@ -315,6 +322,10 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_VERSION), sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION),
) )
if via_device_id:
device_registry.async_update_device(
device_entry.id, via_device_id=via_device_id
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
source_entry.title, source_entry.title,
details, details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID),
) )
return True return True
manager = _get_manager(hass) manager = _get_manager(hass)

View File

@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None, source_domain: str | None = None,
source_model: str | None = None, source_model: str | None = None,
source_config_entry_id: str | None = None, source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a BleakScanner.""" """Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner( return _get_manager(hass).async_register_hass_scanner(
scanner, connection_slots, source_domain, source_model, source_config_entry_id scanner,
connection_slots,
source_domain,
source_model,
source_config_entry_id,
source_device_id,
) )

View File

@ -37,6 +37,7 @@ from .const import (
CONF_PASSIVE, CONF_PASSIVE,
CONF_SOURCE, CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
} }
self._abort_if_unique_id_configured(updates=data) self._abort_if_unique_id_configured(updates=data)
manager = get_manager() manager = get_manager()

View File

@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain" CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model" CONF_SOURCE_MODEL: Final = "source_model"
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
SOURCE_LOCAL: Final = "local" SOURCE_LOCAL: Final = "local"

View File

@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import ( from .const import (
CONF_SOURCE, CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None, source_domain: str | None = None,
source_model: str | None = None, source_model: str | None = None,
source_config_entry_id: str | None = None, source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a scanner.""" """Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots) cancel = self.async_register_scanner(scanner, connection_slots)
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner) isinstance(scanner, BaseHaRemoteScanner)
and source_domain and source_domain
and source_config_entry_id and source_config_entry_id
and not self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, scanner.source
)
): ):
self.hass.async_create_task( self.hass.async_create_task(
self.hass.config_entries.flow.async_init( self.hass.config_entries.flow.async_init(
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain, CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model, CONF_SOURCE_MODEL: source_model,
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
CONF_SOURCE_DEVICE_ID: source_device_id,
}, },
) )
) )

View File

@ -28,6 +28,7 @@ def async_connect_scanner(
entry_data: RuntimeEntryData, entry_data: RuntimeEntryData,
cli: APIClient, cli: APIClient,
device_info: DeviceInfo, device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Connect scanner.""" """Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available) client_data = connect_scanner(cli, device_info, entry_data.available)
@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN, source_domain=DOMAIN,
source_model=device_info.model, source_model=device_info.model,
source_config_entry_id=entry_data.entry_id, source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
), ),
scanner.async_setup(), scanner.async_setup(),
], ],

View File

@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version): if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add( entry_data.disconnect_callbacks.add(
async_connect_scanner(hass, entry_data, cli, device_info) async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
)
) )
else: else:
bluetooth.async_remove_scanner(hass, device_info.mac_address) bluetooth.async_remove_scanner(hass, device_info.mac_address)

View File

@ -21,6 +21,7 @@ async def async_connect_scanner(
hass: HomeAssistant, hass: HomeAssistant,
coordinator: ShellyRpcCoordinator, coordinator: ShellyRpcCoordinator,
scanner_mode: BLEScannerMode, scanner_mode: BLEScannerMode,
device_id: str,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Connect scanner.""" """Connect scanner."""
device = coordinator.device device = coordinator.device
@ -34,6 +35,7 @@ async def async_connect_scanner(
source_domain=entry.domain, source_domain=entry.domain,
source_model=coordinator.model, source_model=coordinator.model,
source_config_entry_id=entry.entry_id, source_config_entry_id=entry.entry_id,
source_device_id=device_id,
), ),
scanner.async_setup(), scanner.async_setup(),
coordinator.async_subscribe_events(scanner.async_on_event), coordinator.async_subscribe_events(scanner.async_on_event),

View File

@ -704,8 +704,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
# BLE enable required a reboot, don't bother connecting # BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway # the scanner since it will be disconnected anyway
return return
assert self.device_id is not None
self._disconnected_callbacks.append( self._disconnected_callbacks.append(
await async_connect_scanner(self.hass, self, ble_scanner_mode) await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
)
) )
@callback @callback

View File

@ -13,12 +13,14 @@ from homeassistant.components.bluetooth.const import (
CONF_PASSIVE, CONF_PASSIVE,
CONF_SOURCE, CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import FakeRemoteScanner, MockBleakClient, _get_manager from . import FakeRemoteScanner, MockBleakClient, _get_manager
@ -535,34 +537,33 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) ->
@pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.usefixtures("enable_bluetooth")
async def test_async_step_integration_discovery_remote_adapter( async def test_async_step_integration_discovery_remote_adapter(
hass: HomeAssistant, hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None: ) -> None:
"""Test remote adapter configuration via integration discovery.""" """Test remote adapter configuration via integration discovery."""
entry = MockConfigEntry(domain="test") entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
connector = ( connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
) )
scanner = FakeRemoteScanner("esp32", "esp32", connector, True) scanner = FakeRemoteScanner("esp32", "esp32", connector, True)
manager = _get_manager() manager = _get_manager()
cancel_scanner = manager.async_register_scanner(scanner) cancel_scanner = manager.async_register_scanner(scanner)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "BB:BB:BB:BB:BB:BB")},
)
entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init(
with ( DOMAIN,
patch("homeassistant.components.bluetooth.async_setup", return_value=True), context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
patch( data={
"homeassistant.components.bluetooth.async_setup_entry", return_value=True CONF_SOURCE: scanner.source,
) as mock_setup_entry, CONF_SOURCE_DOMAIN: "test",
): CONF_SOURCE_MODEL: "test",
result = await hass.config_entries.flow.async_init( CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
DOMAIN, CONF_SOURCE_DEVICE_ID: device_entry.id,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, },
data={ )
CONF_SOURCE: scanner.source,
CONF_SOURCE_DOMAIN: "test",
CONF_SOURCE_MODEL: "test",
CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "esp32" assert result["title"] == "esp32"
assert result["data"] == { assert result["data"] == {
@ -570,9 +571,22 @@ async def test_async_step_integration_discovery_remote_adapter(
CONF_SOURCE_DOMAIN: "test", CONF_SOURCE_DOMAIN: "test",
CONF_SOURCE_MODEL: "test", CONF_SOURCE_MODEL: "test",
CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
CONF_SOURCE_DEVICE_ID: device_entry.id,
} }
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done() await hass.async_block_till_done()
new_entry_id: str = result["result"].entry_id
new_entry = hass.config_entries.async_get_entry(new_entry_id)
assert new_entry is not None
assert new_entry.state is config_entries.ConfigEntryState.LOADED
ble_device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_BLUETOOTH, scanner.source)}
)
assert ble_device_entry is not None
assert ble_device_entry.via_device_id == device_entry.id
await hass.config_entries.async_unload(new_entry.entry_id)
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
cancel_scanner() cancel_scanner()

View File

@ -4,6 +4,7 @@ from unittest.mock import patch
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import MockESPHomeDevice from .conftest import MockESPHomeDevice
@ -48,6 +49,34 @@ async def test_bluetooth_connect_with_legacy_adv(
assert scanner.scanning is True assert scanner.scanning is True
async def test_bluetooth_device_linked_via_device(
hass: HomeAssistant,
mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice,
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")
assert scanner.connectable is True
entry = hass.config_entries.async_entry_for_domain_unique_id(
"bluetooth", "11:22:33:44:55:AA"
)
assert entry is not None
esp_device = device_registry.async_get_device(
connections={
(
dr.CONNECTION_NETWORK_MAC,
mock_bluetooth_entry_with_raw_adv.device_info.mac_address,
)
}
)
assert esp_device is not None
device = device_registry.async_get_device(
connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")}
)
assert device is not None
assert device.via_device_id == esp_device.id
async def test_bluetooth_cleanup_on_remove_entry( async def test_bluetooth_cleanup_on_remove_entry(
hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice
) -> None: ) -> None: