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_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -297,7 +298,12 @@ async def async_discover_adapters(
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:
"""Update device registry entry.
@ -306,7 +312,8 @@ async def async_update_device(
update the device with the new location so they can
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,
name=adapter_human_name(adapter, 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),
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:
@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
source_entry.title,
details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID),
)
return True
manager = _get_manager(hass)

View File

@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
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_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
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)
manager = get_manager()

View File

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

View File

@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots)
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner)
and source_domain
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.config_entries.flow.async_init(
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model,
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,
cli: APIClient,
device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],

View File

@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
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:
bluetooth.async_remove_scanner(hass, device_info.mac_address)

View File

@ -21,6 +21,7 @@ async def async_connect_scanner(
hass: HomeAssistant,
coordinator: ShellyRpcCoordinator,
scanner_mode: BLEScannerMode,
device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
device = coordinator.device
@ -34,6 +35,7 @@ async def async_connect_scanner(
source_domain=entry.domain,
source_model=coordinator.model,
source_config_entry_id=entry.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
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
# the scanner since it will be disconnected anyway
return
assert self.device_id is not None
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

View File

@ -13,12 +13,14 @@ from homeassistant.components.bluetooth.const import (
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
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")
async def test_async_step_integration_discovery_remote_adapter(
hass: HomeAssistant,
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test remote adapter configuration via integration discovery."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeRemoteScanner("esp32", "esp32", connector, True)
manager = _get_manager()
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)
with (
patch("homeassistant.components.bluetooth.async_setup", return_value=True),
patch(
"homeassistant.components.bluetooth.async_setup_entry", return_value=True
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
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,
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
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,
CONF_SOURCE_DEVICE_ID: device_entry.id,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "esp32"
assert result["data"] == {
@ -570,9 +571,22 @@ async def test_async_step_integration_discovery_remote_adapter(
CONF_SOURCE_DOMAIN: "test",
CONF_SOURCE_MODEL: "test",
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()
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.async_block_till_done()
cancel_scanner()

View File

@ -4,6 +4,7 @@ from unittest.mock import patch
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import MockESPHomeDevice
@ -48,6 +49,34 @@ async def test_bluetooth_connect_with_legacy_adv(
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(
hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice
) -> None: