diff --git a/docs/bluetooth.md b/docs/bluetooth.md index b2811082..464cff04 100644 --- a/docs/bluetooth.md +++ b/docs/bluetooth.md @@ -1,6 +1,6 @@ --- title: "Bluetooth" -sidebar_label: "Bluetooth" +sidebar_label: "Best practices" --- ### Best practices for integration authors @@ -96,7 +96,7 @@ Integrations that need an instance of a `BleakScanner` should call the `bluetoot ```python from homeassistant.components import bluetooth - + scanner = bluetooth.async_get_scanner(hass) ``` @@ -122,7 +122,7 @@ from homeassistant.components import bluetooth def _unavailable_callback(info: bluetooth.BluetoothServiceInfoBleak) -> None: _LOGGER.debug("%s is no longer seen", info.address) - + cancel = bluetooth.async_track_unavailable(hass, _unavailable_callback, "44:44:33:11:23:42", connectable=True) ``` diff --git a/docs/core/bluetooth/bluetooth_fetching_data.md b/docs/core/bluetooth/bluetooth_fetching_data.md new file mode 100644 index 00000000..2b639bbf --- /dev/null +++ b/docs/core/bluetooth/bluetooth_fetching_data.md @@ -0,0 +1,398 @@ +--- +title: "Fetching Bluetooth Data" +--- + +## Choosing a method to fetch data + +If the device's primary method to notify of updates is Bluetooth advertisements and its primary function is a sensor, binary sensor, or firing events: + +- If all sensors are updated via Bluetooth advertisements: [`PassiveBluetoothProcessorCoordinator`](#passivebluetoothprocessorcoordinator) +- If active connection are needed for some sensors: [`ActiveBluetoothProcessorCoordinator`](#activebluetoothprocessorcoordinator) + +If the device's primary method to notify of updates is Bluetooth advertisements and its primary function is **not** a sensor, binary sensor, or firing events: + +- If all entities are updated via Bluetooth advertisements: [`PassiveBluetoothCoordinator`](#passivebluetoothcoordinator) +- If active connections are needed: [`ActiveBluetoothCoordinator`](#activebluetoothcoordinator) + +If your device only communicates with an active Bluetooth connection and does not use Bluetooth advertisements: + +- [`DataUpdateCoordinator`](../../integration_fetching_data) + +## BluetoothProcessorCoordinator + +The `ActiveBluetoothProcessorCoordinator` and `PassiveBluetoothProcessorCoordinator` significantly reduce the code needed for creating integrations that primary function as sensor, binary sensors, or fire events. By formatting the data fed into the processor coordinators into a `PassiveBluetoothDataUpdate` object, the +frameworks can take care of creating the entities on demand and allow for minimal `sensor` and `binary_sensor` platform implementations. + +These frameworks require the data coming from the library to be formatted into a `PassiveBluetoothDataUpdate` as shown below: + +```python +@dataclasses.dataclass(frozen=True) +class PassiveBluetoothEntityKey: + """Key for a passive bluetooth entity. + + Example: + key: temperature + device_id: outdoor_sensor_1 + """ + + key: str + device_id: str | None + +@dataclasses.dataclass(frozen=True) +class PassiveBluetoothDataUpdate(Generic[_T]): + """Generic bluetooth data.""" + + devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) + entity_descriptions: Mapping[ + PassiveBluetoothEntityKey, EntityDescription + ] = dataclasses.field(default_factory=dict) + entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field( + default_factory=dict + ) + entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field( + default_factory=dict + ) +``` + +### PassiveBluetoothProcessorCoordinator + +Example `async_setup_entry` for an integration `__init__.py` using a `PassiveBluetoothProcessorCoordinator`: + +```python +import logging +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from .const import DOMAIN +from homeassistant.const import Platform + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +from your_library import DataParser + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up example BLE device from a config entry.""" + address = entry.unique_id + data = DataParser() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + # only start after all platforms have had a chance to subscribe + coordinator.async_start() + ) + return True +``` + +Example `sensor.py`: + +```python +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import SensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + + +def sensor_update_to_bluetooth_data_update(parsed_data): + """Convert a sensor update to a Bluetooth data update.""" + # This function must convert the parsed_data + # from your library's update_method to a `PassiveBluetoothDataUpdate` + # See the structure above + return PassiveBluetoothDataUpdate( + devices={}, + entity_descriptions={}, + entity_data={}, + entity_names={}, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the example BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + ExampleBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class ExampleBluetoothSensorEntity(PassiveBluetoothProcessorEntity, SensorEntity): + """Representation of an example BLE sensor.""" + + @property + def native_value(self) -> float | int | str | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) + +``` + +### ActiveBluetoothProcessorCoordinator + +An `ActiveBluetoothProcessorCoordinator` functions nearly the same as a `PassiveBluetoothProcessorCoordinator` +but will also make an active connection to poll for data based on `needs_poll_method` and a `poll_method` +function which are called when the device's Bluetooth advertisement changes. The `sensor.py` implementation +is the same as the `PassiveBluetoothProcessorCoordinator`. + +Example `async_setup_entry` for an integration `__init__.py` using an `ActiveBluetoothProcessorCoordinator`: + +```python +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.components.bluetooth import BluetoothScanningMode + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_ble_device_from_address, +) +from homeassistant.const import Platform + +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +PLATFORMS: list[Platform] = [Platform.SENSOR] + +from your_library import DataParser + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up example BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = DataParser() + + def _needs_poll( + service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + hass.state == CoreState.running + and data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + hass, service_info.device.address, connectable=True + ) + ) + ) + + async def _async_poll(service_info: BluetoothServiceInfoBleak): + if service_info.connectable: + connectable_device = service_info.device + elif device := async_ble_device_from_address( + hass, service_info.device.address, True + ): + connectable_device = device + else: + # We have no Bluetooth controller that is in range of + # the device to poll it + raise RuntimeError( + f"No connectable device found for {service_info.device.address}" + ) + return await data.async_poll(connectable_device) + + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = ActiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, + needs_poll_method=_needs_poll, + poll_method=_async_poll, + # We will take advertisements from non-connectable devices + # since we will trade the BLEDevice for a connectable one + # if we need to poll it + connectable=False, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + # only start after all platforms have had a chance to subscribe + coordinator.async_start() + ) + return True +``` + +## BluetoothCoordinator + +The `ActiveBluetoothCoordinator` and `PassiveBluetoothCoordinator` coordinators function similar +to `DataUpdateCoordinators` except they are driven by incoming advertisement data instead of polling. + +### PassiveBluetoothCoordinator + +Below is an example of a `PassiveBluetoothDataUpdateCoordinator`. Incoming +data is received via `_async_handle_bluetooth_event` and processed by the integration's +library. + +```python +import logging +from typing import TYPE_CHECKING + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.active_update_coordinator import ( + PassiveBluetoothDataUpdateCoordinator, +) +from homeassistant.core import CoreState, HomeAssistant, callback + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + + +class ExamplePassiveBluetoothDataUpdateCoordinator( + PassiveBluetoothDataUpdateCoordinator[None] +): + """Class to manage fetching example data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + ble_device: BLEDevice, + device: YourLibDevice, + ) -> None: + """Initialize example data coordinator.""" + super().__init__( + hass=hass, + logger=logger, + address=ble_device.address, + mode=bluetooth.BluetoothScanningMode.ACTIVE, + connectable=False, + ) + self.device = device + + @callback + def _async_handle_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Handle the device going unavailable.""" + + @callback + def _async_handle_bluetooth_event( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + # Your device should process incoming advertisement data + +``` + +### ActiveBluetoothCoordinator + +Below is an example of an `ActiveBluetoothDataUpdateCoordinator`. Incoming +data is received via `_async_handle_bluetooth_event` and processed by the integration's +library. + +The `_needs_poll` function is called each time there is the Bluetooth advertisement changes to +determine if an active connection to the device is needed to fetch additional data. + +If the `_needs_poll` function returns `True`, `_async_update` will be called. + +```python +import logging +from typing import TYPE_CHECKING + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.active_update_coordinator import ( + ActiveBluetoothDataUpdateCoordinator, +) +from homeassistant.core import CoreState, HomeAssistant, callback + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + + +class ExampleActiveBluetoothDataUpdateCoordinator( + ActiveBluetoothDataUpdateCoordinator[None] +): + """Class to manage fetching example data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + ble_device: BLEDevice, + device: YourLibDevice, + ) -> None: + """Initialize example data coordinator.""" + super().__init__( + hass=hass, + logger=logger, + address=ble_device.address, + needs_poll_method=self._needs_poll, + poll_method=self._async_update, + mode=bluetooth.BluetoothScanningMode.ACTIVE, + connectable=True, + ) + self.device = device + + @callback + def _needs_poll( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + seconds_since_last_poll: float | None, + ) -> bool: + # Only poll if hass is running, we need to poll, + # and we actually have a way to connect to the device + return ( + self.hass.state == CoreState.running + and self.device.poll_needed(seconds_since_last_poll) + and bool( + bluetooth.async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + async def _async_update( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Poll the device.""" + + @callback + def _async_handle_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Handle the device going unavailable.""" + + @callback + def _async_handle_bluetooth_event( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + # Your device should process incoming advertisement data + +``` diff --git a/sidebars.js b/sidebars.js index 875c07ca..1cc04e13 100644 --- a/sidebars.js +++ b/sidebars.js @@ -111,7 +111,11 @@ module.exports = { "integration_events", "integration_listen_events", "network_discovery", - "bluetooth", + { + type: "category", + label: "Bluetooth", + items: ["bluetooth", "core/bluetooth/bluetooth_fetching_data"], + }, ], }, {