Wiring the DBus-free local scanner into Home Assistant (experimental)

Experimental. The management/L2CAP path (HaScannerMgmt, HaMgmtClient, create_local_scanner) is a work in progress; the API may change or be renamed. Nothing here is on by default — Home Assistant still constructs the bleak-backed HaScanner until this wiring lands.

The DBus-free path drives a local adapter entirely over the BlueZ management socket and a raw L2CAP ATT channel (discovery, connect, pairing/bonding), bypassing bluetoothd/DBus. habluetooth exposes everything Home Assistant needs to opt in; the only change is in homeassistant/components/bluetooth.

1. Select the scanner via the factory

Today the bluetooth component constructs the scanner directly (homeassistant/components/bluetooth/__init__.py):

scanner = HaScanner(mode, adapter, address)

Swap that for the factory. It returns HaScannerMgmt when the management path is usable, and falls back to the bleak-backed HaScanner otherwise, so it is safe everywhere:

from habluetooth import create_local_scanner

scanner = create_local_scanner(mode, adapter, address)

The returned object is a BaseHaScanner, so the rest of the flow is unchanged: scanner.async_setup(), async_register_scanner(hass, scanner, connection_slots=slots), then await scanner.async_start(). The advertisement side-channel, connection slots, and registration all work as before.

When the factory picks the mgmt scanner

create_local_scanner returns HaScannerMgmt only when all hold; otherwise it returns HaScanner:

  • the host is Linux,

  • the management socket is available and reports discovery capability (CAP_NET_ADMIN/CAP_NET_RAW),

  • the adapter is an hciN adapter,

  • a real adapter BD_ADDR is known (not DEFAULT_ADDRESS),

  • the mode is not AUTO (the mgmt scanner does not yet implement active-window promotion, so AUTO stays on the bleak path).

Note Home Assistant already converts AUTO to ACTIVE for adapters that cannot do passive scanning before this call, so on those adapters the mgmt scanner is eligible.

Controller ownership (required before enabling the connect path)

The LE long-term-key table belongs to the controller and its kernel, not to a process or container. AF_BLUETOOTH/HCI control sockets are not network-namespaced, so a habluetooth running in the Home Assistant container and a bluetoothd running on the host share the same hciN and the same kernel bond table. The typical deployment (HA in a container, host bluetoothd) is therefore a single shared controller, not two isolated ones.

The mgmt discovery side already coexists with host bluetoothd (it only reads advertisement events; that is the existing side channel). The mgmt connect/pair path does not: LOAD_LONG_TERM_KEYS, mgmt pairing, and the L2CAP ACL/GATT are stateful on the shared controller and collide with a bluetoothd that is still managing the same hciN. In particular LOAD_LONG_TERM_KEYS replaces the controller’s whole key list, clearing the bonds bluetoothd loaded; those keys cannot be recovered (DBus never exposes key material, and bluetoothd’s /var/lib/bluetooth store is not reachable from the HA container).

So the model is per-adapter partitioning, never co-management of one hciN:

  • Controllers bluetoothd manages stay on the bleak/DBus path (today’s behavior); their bonds remain bluetoothd’s and are untouched.

  • The mgmt connect path may own a controller only if bluetoothd is not managing it (for example a dedicated adapter the host bluetooth service is configured to ignore). It then creates its own bonds from the first pairing, so LOAD_LONG_TERM_KEYS only ever replaces its own list.

Factory gap. create_local_scanner currently gates only on mgmt discovery capability and L2CAP availability; it has no signal for “this controller is not also managed by bluetoothd.” Until that exclusivity gate exists, treat the connect path as safe only on a controller you know bluetoothd does not manage, and keep the opt-in above scoped to such adapters.

2. Persist bonds across restarts

Pairing over mgmt yields long-term keys that habluetooth keeps in memory only. To survive a restart without re-pairing, the component persists them with its own Store and seeds them back on startup. The scanner exposes the hooks; see Bond key persistence for the full worked example. In short, for each scanner that is an HaScannerMgmt:

from habluetooth import HaScannerMgmt

if isinstance(scanner, HaScannerMgmt):
    bond_storage.async_attach(scanner)  # restore_long_term_keys + change callback

Attach before async_start() so restored keys are loaded before the first connection, and clear the change callback (set_long_term_keys_changed_callback(None)) when the scanner is torn down.

What does not change

  • Scanner registration, connection-slot accounting, and the unavailable-tracking timer are inherited from BaseHaScanner and behave as before.

  • Remote (ESPHome) scanners and non-Linux/macOS local adapters are untouched; the factory returns the existing HaScanner for them.

  • No public API is removed; HaScanner remains available for the fallback path.