From 7d427ddbd4b5eca09b0dfa6f7523e2efd2af5afc Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 9 Aug 2022 06:36:39 +0100 Subject: [PATCH] Allow parsing to happen in PassiveBluetoothProcessorCoordinator (#76384) --- .../bluetooth/passive_update_processor.py | 49 +++-- .../components/govee_ble/__init__.py | 4 + homeassistant/components/govee_ble/sensor.py | 16 +- homeassistant/components/inkbird/__init__.py | 9 +- homeassistant/components/inkbird/sensor.py | 16 +- homeassistant/components/moat/__init__.py | 9 +- homeassistant/components/moat/sensor.py | 16 +- .../components/sensorpush/__init__.py | 9 +- homeassistant/components/sensorpush/sensor.py | 16 +- .../components/xiaomi_ble/__init__.py | 44 +++- homeassistant/components/xiaomi_ble/sensor.py | 40 +--- .../test_passive_update_processor.py | 190 ++++++++++++++++-- 12 files changed, 288 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 78966d9b7ab..bb9e82a7dbe 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -52,11 +52,17 @@ class PassiveBluetoothDataUpdate(Generic[_T]): ) -class PassiveBluetoothProcessorCoordinator(BasePassiveBluetoothCoordinator): +class PassiveBluetoothProcessorCoordinator( + Generic[_T], BasePassiveBluetoothCoordinator +): """Passive bluetooth processor coordinator for bluetooth advertisements. The coordinator is responsible for dispatching the bluetooth data, to each processor, and tracking devices. + + The update_method should return the data that is dispatched to each processor. + This is normally a parsed form of the data, but you can just forward the + BluetoothServiceInfoBleak if needed. """ def __init__( @@ -65,10 +71,18 @@ class PassiveBluetoothProcessorCoordinator(BasePassiveBluetoothCoordinator): logger: logging.Logger, address: str, mode: BluetoothScanningMode, + update_method: Callable[[BluetoothServiceInfoBleak], _T], ) -> None: """Initialize the coordinator.""" super().__init__(hass, logger, address, mode) self._processors: list[PassiveBluetoothDataProcessor] = [] + self._update_method = update_method + self.last_update_success = True + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.last_update_success @callback def async_register_processor( @@ -102,8 +116,22 @@ class PassiveBluetoothProcessorCoordinator(BasePassiveBluetoothCoordinator): super()._async_handle_bluetooth_event(service_info, change) if self.hass.is_stopping: return + + try: + update = self._update_method(service_info) + except Exception as err: # pylint: disable=broad-except + self.last_update_success = False + self.logger.exception( + "Unexpected error updating %s data: %s", self.name, err + ) + return + + if not self.last_update_success: + self.last_update_success = True + self.logger.info("Coordinator %s recovered", self.name) + for processor in self._processors: - processor.async_handle_bluetooth_event(service_info, change) + processor.async_handle_update(update) _PassiveBluetoothDataProcessorT = TypeVar( @@ -123,9 +151,8 @@ class PassiveBluetoothDataProcessor(Generic[_T]): the appropriate format. The processor will call the update_method every time the bluetooth device - receives a new advertisement data from the coordinator with the following signature: - - update_method(service_info: BluetoothServiceInfoBleak) -> PassiveBluetoothDataUpdate + receives a new advertisement data from the coordinator with the data + returned by he update_method of the coordinator. As the size of each advertisement is limited, the update_method should return a PassiveBluetoothDataUpdate object that contains only data that @@ -138,9 +165,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): def __init__( self, - update_method: Callable[ - [BluetoothServiceInfoBleak], PassiveBluetoothDataUpdate[_T] - ], + update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], ) -> None: """Initialize the coordinator.""" self.coordinator: PassiveBluetoothProcessorCoordinator @@ -244,14 +269,10 @@ class PassiveBluetoothDataProcessor(Generic[_T]): update_callback(data) @callback - def async_handle_bluetooth_event( - self, - service_info: BluetoothServiceInfoBleak, - change: BluetoothChange, - ) -> None: + def async_handle_update(self, update: _T) -> None: """Handle a Bluetooth event.""" try: - new_data = self.update_method(service_info) + new_data = self.update_method(update) except Exception as err: # pylint: disable=broad-except self.last_update_success = False self.coordinator.logger.exception( diff --git a/homeassistant/components/govee_ble/__init__.py b/homeassistant/components/govee_ble/__init__.py index 7a134e43ace..a2dbecf85a2 100644 --- a/homeassistant/components/govee_ble/__init__.py +++ b/homeassistant/components/govee_ble/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from govee_ble import GoveeBluetoothDeviceData + from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, @@ -22,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Govee BLE device from a config entry.""" address = entry.unique_id assert address is not None + data = GoveeBluetoothDeviceData() coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( @@ -29,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index d0b9447d9e3..4faa6befa06 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -3,14 +3,7 @@ from __future__ import annotations from typing import Optional, Union -from govee_ble import ( - DeviceClass, - DeviceKey, - GoveeBluetoothDeviceData, - SensorDeviceInfo, - SensorUpdate, - Units, -) +from govee_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -129,12 +122,7 @@ async def async_setup_entry( coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - data = GoveeBluetoothDeviceData() - processor = PassiveBluetoothDataProcessor( - lambda service_info: sensor_update_to_bluetooth_data_update( - data.update(service_info) - ) - ) + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( GoveeBluetoothSensorEntity, async_add_entities diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 0272114b83c..5ed0d6fb367 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from inkbird_ble import INKBIRDBluetoothDeviceData + from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, @@ -22,10 +24,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" address = entry.unique_id assert address is not None + data = INKBIRDBluetoothDeviceData() coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE + 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( diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 0648ca80383..71d6f00ea40 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -3,14 +3,7 @@ from __future__ import annotations from typing import Optional, Union -from inkbird_ble import ( - DeviceClass, - DeviceKey, - INKBIRDBluetoothDeviceData, - SensorDeviceInfo, - SensorUpdate, - Units, -) +from inkbird_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -129,12 +122,7 @@ async def async_setup_entry( coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - data = INKBIRDBluetoothDeviceData() - processor = PassiveBluetoothDataProcessor( - lambda service_info: sensor_update_to_bluetooth_data_update( - data.update(service_info) - ) - ) + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities diff --git a/homeassistant/components/moat/__init__.py b/homeassistant/components/moat/__init__.py index 237948a8ff6..ed360f53b65 100644 --- a/homeassistant/components/moat/__init__.py +++ b/homeassistant/components/moat/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from moat_ble import MoatBluetoothDeviceData + from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, @@ -22,10 +24,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Moat BLE device from a config entry.""" address = entry.unique_id assert address is not None + data = MoatBluetoothDeviceData() coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, address=address, mode=BluetoothScanningMode.PASSIVE + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py index 295e2877aed..c5e02a38dcd 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -3,14 +3,7 @@ from __future__ import annotations from typing import Optional, Union -from moat_ble import ( - DeviceClass, - DeviceKey, - MoatBluetoothDeviceData, - SensorDeviceInfo, - SensorUpdate, - Units, -) +from moat_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -136,12 +129,7 @@ async def async_setup_entry( coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - data = MoatBluetoothDeviceData() - processor = PassiveBluetoothDataProcessor( - lambda service_info: sensor_update_to_bluetooth_data_update( - data.update(service_info) - ) - ) + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( MoatBluetoothSensorEntity, async_add_entities diff --git a/homeassistant/components/sensorpush/__init__.py b/homeassistant/components/sensorpush/__init__.py index d4a0872ba3f..7828a581d07 100644 --- a/homeassistant/components/sensorpush/__init__.py +++ b/homeassistant/components/sensorpush/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from sensorpush_ble import SensorPushBluetoothDeviceData + from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, @@ -22,10 +24,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SensorPush BLE device from a config entry.""" address = entry.unique_id assert address is not None + data = SensorPushBluetoothDeviceData() coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, address=address, mode=BluetoothScanningMode.PASSIVE + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 9bfa59e3876..8a4db7aff14 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -3,14 +3,7 @@ from __future__ import annotations from typing import Optional, Union -from sensorpush_ble import ( - DeviceClass, - DeviceKey, - SensorDeviceInfo, - SensorPushBluetoothDeviceData, - SensorUpdate, - Units, -) +from sensorpush_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -130,12 +123,7 @@ async def async_setup_entry( coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - data = SensorPushBluetoothDeviceData() - processor = PassiveBluetoothDataProcessor( - lambda service_info: sensor_update_to_bluetooth_data_update( - data.update(service_info) - ) - ) + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( SensorPushBluetoothSensorEntity, async_add_entities diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 791ac1447ad..e3e30e0c79e 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -3,7 +3,14 @@ from __future__ import annotations import logging -from homeassistant.components.bluetooth import BluetoothScanningMode +from xiaomi_ble import SensorUpdate, XiaomiBluetoothDeviceData +from xiaomi_ble.parser import EncryptionScheme + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, ) @@ -18,14 +25,47 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +def process_service_info( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + data: XiaomiBluetoothDeviceData, + service_info: BluetoothServiceInfoBleak, +) -> SensorUpdate: + """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + update = data.update(service_info) + + # If device isn't pending we know it has seen at least one broadcast with a payload + # If that payload was encrypted and the bindkey was not verified then we need to reauth + if ( + not data.pending + and data.encryption_scheme != EncryptionScheme.NONE + and not data.bindkey_verified + ): + entry.async_start_reauth(hass, data={"device": data}) + + return update + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Xiaomi BLE device from a config entry.""" address = entry.unique_id assert address is not None + + kwargs = {} + if bindkey := entry.data.get("bindkey"): + kwargs["bindkey"] = bytes.fromhex(bindkey) + data = XiaomiBluetoothDeviceData(**kwargs) + coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, address=address, mode=BluetoothScanningMode.PASSIVE + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=lambda service_info: process_service_info( + hass, entry, data, service_info + ), ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index dcb95422609..d22ed46dd83 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -3,18 +3,9 @@ from __future__ import annotations from typing import Optional, Union -from xiaomi_ble import ( - DeviceClass, - DeviceKey, - SensorDeviceInfo, - SensorUpdate, - Units, - XiaomiBluetoothDeviceData, -) -from xiaomi_ble.parser import EncryptionScheme +from xiaomi_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units from homeassistant import config_entries -from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, @@ -165,27 +156,6 @@ def sensor_update_to_bluetooth_data_update( ) -def process_service_info( - hass: HomeAssistant, - entry: config_entries.ConfigEntry, - data: XiaomiBluetoothDeviceData, - service_info: BluetoothServiceInfoBleak, -) -> PassiveBluetoothDataUpdate: - """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" - update = data.update(service_info) - - # If device isn't pending we know it has seen at least one broadcast with a payload - # If that payload was encrypted and the bindkey was not verified then we need to reauth - if ( - not data.pending - and data.encryption_scheme != EncryptionScheme.NONE - and not data.bindkey_verified - ): - entry.async_start_reauth(hass, data={"device": data}) - - return sensor_update_to_bluetooth_data_update(update) - - async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, @@ -195,13 +165,7 @@ async def async_setup_entry( coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - kwargs = {} - if bindkey := entry.data.get("bindkey"): - kwargs["bindkey"] = bytes.fromhex(bindkey) - data = XiaomiBluetoothDeviceData(**kwargs) - processor = PassiveBluetoothDataProcessor( - lambda service_info: process_service_info(hass, entry, data, service_info) - ) + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( XiaomiBluetoothSensorEntity, async_add_entities diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 6a092746a68..5653b938ada 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -84,14 +84,25 @@ async def test_basic_usage(hass, mock_bleak_scanner_start): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" + assert data == {"test": "data"} return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -186,14 +197,24 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): await hass.async_block_till_done() @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -271,14 +292,24 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -326,8 +357,14 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta run_count = 0 @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" nonlocal run_count @@ -337,7 +374,11 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -379,8 +420,14 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): run_count = 0 @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" nonlocal run_count @@ -390,7 +437,11 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -721,8 +772,14 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start): update_count = 0 @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" nonlocal update_count @@ -732,7 +789,11 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start): return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -835,14 +896,24 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -899,14 +970,24 @@ async def test_passive_bluetooth_entity_with_entity_platform( entity_platform = MockEntityPlatform(hass) @callback - def _async_generate_mock_data( + def _mock_update_method( service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], ) -> PassiveBluetoothDataUpdate: """Generate mock data.""" return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -992,8 +1073,18 @@ async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_st """Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + coordinator = PassiveBluetoothProcessorCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", BluetoothScanningMode.ACTIVE + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, ) assert coordinator.available is False # no data yet saved_callback = None @@ -1075,3 +1166,68 @@ async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_st key="motion", device_id=None ) cancel_coordinator() + + +async def test_exception_from_coordinator_update_method( + hass, caplog, mock_bleak_scanner_start +): + """Test we handle exceptions from the update method.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + run_count = 0 + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + nonlocal run_count + run_count += 1 + if run_count == 2: + raise Exception("Test exception") + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + saved_callback = None + + def _async_register_callback(_hass, _callback, _matcher, _mode): + nonlocal saved_callback + saved_callback = _callback + return lambda: None + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + processor.async_add_listener(MagicMock()) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert processor.available is True + + # We should go unavailable once we get an exception + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert "Test exception" in caplog.text + assert processor.available is False + + # We should go available again once we get data again + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert processor.available is True + unregister_processor() + cancel_coordinator()