diff --git a/aiohomekit/controller/ble/bleak.py b/aiohomekit/controller/ble/bleak.py index 7a5dbb63..bc6b4588 100644 --- a/aiohomekit/controller/ble/bleak.py +++ b/aiohomekit/controller/ble/bleak.py @@ -7,6 +7,7 @@ from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice +from bleak.exc import BleakError from bleak_retry_connector import BleakClientWithServiceCache from .const import HAP_MIN_REQUIRED_MTU @@ -18,6 +19,14 @@ ATT_HEADER_SIZE = 3 +class BleakCharacteristicMissing(BleakError): + """Raised when a characteristic is missing from a service.""" + + +class BleakServiceMissing(BleakError): + """Raised when a service is missing.""" + + @lru_cache(maxsize=64, typed=True) def _determine_fragment_size( address: str, @@ -132,11 +141,11 @@ async def get_characteristic( available_services = [ service.uuid for service in self.services.services.values() ] - raise ValueError( + raise BleakServiceMissing( f"{self.__name}: Service {service_uuid} not found, available services: {available_services}" ) available_chars = [char.uuid for char in service.characteristics] - raise ValueError( + raise BleakCharacteristicMissing( f"{self.__name}: Characteristic {characteristic_uuid} not found, available characteristics: {available_chars}" ) diff --git a/aiohomekit/controller/ble/pairing.py b/aiohomekit/controller/ble/pairing.py index ea11748b..7d21c19c 100644 --- a/aiohomekit/controller/ble/pairing.py +++ b/aiohomekit/controller/ble/pairing.py @@ -59,7 +59,11 @@ from aiohomekit.uuid import normalize_uuid from ..abstract import AbstractPairing, AbstractPairingData -from .bleak import AIOHomeKitBleakClient +from .bleak import ( + AIOHomeKitBleakClient, + BleakCharacteristicMissing, + BleakServiceMissing, +) from .client import ( PDUStatusError, ble_request, @@ -157,6 +161,31 @@ async def _async_operation_lock_wrap( return cast(WrapFuncType, _async_operation_lock_wrap) +def disconnect_on_missing_services(func: WrapFuncType) -> WrapFuncType: + """Define a wrapper to disconnect on missing services and characteristics. + + This must be placed after the retry_bluetooth_connection_error + decorator. + """ + + async def _async_disconnect_on_missing_services_wrap( + self: BlePairing, *args: Any, **kwargs: Any + ) -> None: + try: + return await func(self, *args, **kwargs) + except (BleakServiceMissing, BleakCharacteristicMissing) as ex: + logger.warning( + "%s: Missing service or characteristic, disconnecting to force refetch of GATT services: %s", + self.name, + ex, + ) + if self.client: + await self.client.disconnect() + raise + + return cast(WrapFuncType, _async_disconnect_on_missing_services_wrap) + + def restore_connection_and_resume(func: WrapFuncType) -> WrapFuncType: """Define a wrapper restore connection, populate data, and then resume when the operation completes.""" @@ -527,6 +556,7 @@ async def _process_disconnected_events(self) -> None: @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def _process_disconnected_events_with_retry( self, @@ -830,6 +860,7 @@ async def _close_while_locked(self) -> None: @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def list_accessories_and_characteristics(self) -> list[dict[str, Any]]: return self.accessories.serialize() @@ -930,6 +961,7 @@ async def async_populate_accessories_state( @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services async def _async_populate_accessories_state( self, force_update: bool = False, attempts: int | None = None ) -> None: @@ -1115,6 +1147,7 @@ async def _async_start_notify_subscriptions(self) -> None: @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services async def _process_config_changed(self, config_num: int) -> None: """Process a config change. @@ -1126,6 +1159,7 @@ async def _process_config_changed(self, config_num: int) -> None: @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def list_pairings(self): request_tlv = TLV.encode_list( @@ -1167,6 +1201,7 @@ async def list_pairings(self): return tmp @retry_bluetooth_connection_error() + @disconnect_on_missing_services async def get_characteristics( self, characteristics: list[tuple[int, int]], @@ -1275,6 +1310,7 @@ async def _get_characteristics_while_connected( @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def put_characteristics( self, characteristics: list[tuple[int, int, Any]] @@ -1351,6 +1387,7 @@ async def subscribe(self, characteristics: Iterable[tuple[int, int]]) -> None: @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def _async_subscribe(self, new_chars: Iterable[tuple[int, int]]) -> None: """Subscribe to new characteristics.""" @@ -1393,6 +1430,7 @@ async def identify(self): @operation_lock @retry_bluetooth_connection_error() + @disconnect_on_missing_services @restore_connection_and_resume async def add_pairing( self, additional_controller_pairing_identifier, ios_device_ltpk, permissions @@ -1449,6 +1487,7 @@ async def add_pairing( @operation_lock @retry_bluetooth_connection_error(attempts=10) + @disconnect_on_missing_services @restore_connection_and_resume async def remove_pairing(self, pairingId: str) -> bool: request_tlv = TLV.encode_list(