"""The bluetooth integration.""" from __future__ import annotations from collections.abc import Callable, Iterable from functools import partial import itertools import logging from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import BluetoothAdapters from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, HomeAssistant, callback as hass_callback, ) from homeassistant.helpers import discovery_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, ) from .match import ( ADDRESS, CALLBACK, CONNECTABLE, BluetoothCallbackMatcher, BluetoothCallbackMatcherIndex, BluetoothCallbackMatcherWithCallback, IntegrationMatcher, ble_device_matches, ) from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak from .storage import BluetoothStorage from .util import async_load_history_from_system _LOGGER = logging.getLogger(__name__) class HomeAssistantBluetoothManager(BluetoothManager): """Manage Bluetooth for Home Assistant.""" __slots__ = ( "_callback_index", "_cancel_logging_listener", "_integration_matcher", "hass", "storage", ) def __init__( self, hass: HomeAssistant, integration_matcher: IntegrationMatcher, bluetooth_adapters: BluetoothAdapters, storage: BluetoothStorage, slot_manager: BleakSlotManager, ) -> None: """Init bluetooth manager.""" self.hass = hass self.storage = storage self._integration_matcher = integration_matcher self._callback_index = BluetoothCallbackMatcherIndex() self._cancel_logging_listener: CALLBACK_TYPE | None = None super().__init__(bluetooth_adapters, slot_manager) self._async_logging_changed() @hass_callback def _async_logging_changed(self, event: Event | None = None) -> None: """Handle logging change.""" self._debug = _LOGGER.isEnabledFor(logging.DEBUG) def _async_trigger_matching_discovery( self, service_info: BluetoothServiceInfoBleak ) -> None: """Trigger discovery for matching domains.""" discovery_key = discovery_flow.DiscoveryKey( domain=DOMAIN, key=service_info.address, version=1, ) for domain in self._integration_matcher.match_domains(service_info): discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_BLUETOOTH}, service_info, discovery_key=discovery_key, ) @hass_callback def async_rediscover_address(self, address: str) -> None: """Trigger discovery of devices which have already been seen.""" self._integration_matcher.async_clear_address(address) if service_info := self._connectable_history.get(address): self._async_trigger_matching_discovery(service_info) return if service_info := self._all_history.get(address): self._async_trigger_matching_discovery(service_info) def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: matched_domains = self._integration_matcher.match_domains(service_info) if self._debug: _LOGGER.debug( "%s: %s match: %s", self._async_describe_source(service_info), service_info, matched_domains, ) for match in self._callback_index.match_callbacks(service_info): callback = match[CALLBACK] try: callback(service_info, BluetoothChange.ADVERTISEMENT) except Exception: _LOGGER.exception("Error in bluetooth callback") if not matched_domains: return # avoid creating DiscoveryKey if there are no matches discovery_key = discovery_flow.DiscoveryKey( domain=DOMAIN, key=service_info.address, version=1, ) for domain in matched_domains: discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_BLUETOOTH}, service_info, discovery_key=discovery_key, ) def _address_disappeared(self, address: str) -> None: """Dismiss all discoveries for the given address.""" self._integration_matcher.async_clear_address(address) for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( BluetoothServiceInfoBleak, lambda service_info: bool(service_info.address == address), ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) async def async_setup(self) -> None: """Set up the bluetooth manager.""" await super().async_setup() self._all_history, self._connectable_history = async_load_history_from_system( self._bluetooth_adapters, self.storage ) self._cancel_logging_listener = self.hass.bus.async_listen( EVENT_LOGGING_CHANGED, self._async_logging_changed ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) seen: set[str] = set() for address, service_info in itertools.chain( self._connectable_history.items(), self._all_history.items() ): if address in seen: continue seen.add(address) self._async_trigger_matching_discovery(service_info) async_dispatcher_connect( self.hass, config_entries.signal_discovered_config_entry_removed(DOMAIN), self._handle_config_entry_removed, ) def async_register_callback( self, callback: BluetoothCallback, matcher: BluetoothCallbackMatcher | None, ) -> Callable[[], None]: """Register a callback.""" callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) if not matcher: callback_matcher[CONNECTABLE] = True else: # We could write out every item in the typed dict here # but that would be a bit inefficient and verbose. callback_matcher.update(matcher) callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) connectable = callback_matcher[CONNECTABLE] self._callback_index.add_callback_matcher(callback_matcher) def _async_remove_callback() -> None: self._callback_index.remove_callback_matcher(callback_matcher) # If we have history for the subscriber, we can trigger the callback # immediately with the last packet so the subscriber can see the # device. history = self._connectable_history if connectable else self._all_history service_infos: Iterable[BluetoothServiceInfoBleak] = [] if address := callback_matcher.get(ADDRESS): if service_info := history.get(address): service_infos = [service_info] else: service_infos = history.values() for service_info in service_infos: if ble_device_matches(callback_matcher, service_info): try: callback(service_info, BluetoothChange.ADVERTISEMENT) except Exception: _LOGGER.exception("Error in bluetooth callback") return _async_remove_callback @hass_callback def async_stop(self, event: Event | None = None) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self._async_save_scanner_histories() super().async_stop() if self._cancel_logging_listener: self._cancel_logging_listener() self._cancel_logging_listener = None def _async_save_scanner_histories(self) -> None: """Save the scanner histories.""" for scanner in itertools.chain( self._connectable_scanners, self._non_connectable_scanners ): self._async_save_scanner_history(scanner) def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: """Save the scanner history.""" if isinstance(scanner, BaseHaRemoteScanner): self.storage.async_set_advertisement_history( scanner.source, scanner.serialize_discovered_devices() ) def _async_unregister_scanner( self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE ) -> None: """Unregister a scanner.""" unregister() self._async_save_scanner_history(scanner) @hass_callback def async_register_hass_scanner( self, scanner: BaseHaScanner, connection_slots: int | None = None, source_domain: str | None = None, source_model: str | None = None, source_config_entry_id: str | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" cancel = self.async_register_scanner(scanner, connection_slots) if ( 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( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ CONF_SOURCE: scanner.source, CONF_SOURCE_DOMAIN: source_domain, CONF_SOURCE_MODEL: source_model, CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, }, ) ) return cancel def async_register_scanner( self, scanner: BaseHaScanner, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" if isinstance(scanner, BaseHaRemoteScanner): if history := self.storage.async_get_advertisement_history(scanner.source): scanner.restore_discovered_devices(history) unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) @hass_callback def async_remove_scanner(self, source: str) -> None: """Remove a scanner.""" self.storage.async_remove_advertisement_history(source) if entry := self.hass.config_entries.async_entry_for_domain_unique_id( DOMAIN, source ): self.hass.async_create_task( self.hass.config_entries.async_remove(entry.entry_id), f"Removing {source} Bluetooth config entry", ) @hass_callback def _handle_config_entry_removed( self, entry: config_entries.ConfigEntry, ) -> None: """Handle config entry changes.""" for discovery_key in entry.discovery_keys[DOMAIN]: if discovery_key.version != 1 or not isinstance(discovery_key.key, str): continue address = discovery_key.key _LOGGER.debug("Rediscover address %s", address) self.async_rediscover_address(address)