1# Copyright 2021-2022 Google LLC 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15# ----------------------------------------------------------------------------- 16# GATT - Generic Attribute Profile 17# Client 18# 19# See Bluetooth spec @ Vol 3, Part G 20# 21# ----------------------------------------------------------------------------- 22 23# ----------------------------------------------------------------------------- 24# Imports 25# ----------------------------------------------------------------------------- 26import asyncio 27import logging 28import struct 29from colors import color 30 31from .core import ProtocolError, TimeoutError 32from .hci import * 33from .att import * 34from .gatt import ( 35 GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR, 36 GATT_REQUEST_TIMEOUT, 37 GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, 38 GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, 39 GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, 40 Characteristic 41) 42 43# ----------------------------------------------------------------------------- 44# Logging 45# ----------------------------------------------------------------------------- 46logger = logging.getLogger(__name__) 47 48 49# ----------------------------------------------------------------------------- 50# Proxies 51# ----------------------------------------------------------------------------- 52class AttributeProxy(EventEmitter): 53 def __init__(self, client, handle, end_group_handle, attribute_type): 54 EventEmitter.__init__(self) 55 self.client = client 56 self.handle = handle 57 self.end_group_handle = end_group_handle 58 self.type = attribute_type 59 60 async def read_value(self, no_long_read=False): 61 return await self.client.read_value(self.handle, no_long_read) 62 63 async def write_value(self, value, with_response=False): 64 return await self.client.write_value(self.handle, value, with_response) 65 66 def __str__(self): 67 return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})' 68 69 70class ServiceProxy(AttributeProxy): 71 @staticmethod 72 def from_client(cls, client, service_uuid): 73 # The service and its characteristics are considered to have already been discovered 74 services = client.get_services_by_uuid(service_uuid) 75 service = services[0] if services else None 76 return cls(service) if service else None 77 78 def __init__(self, client, handle, end_group_handle, uuid, primary=True): 79 attribute_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE if primary else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE 80 super().__init__(client, handle, end_group_handle, attribute_type) 81 self.uuid = uuid 82 self.characteristics = [] 83 84 async def discover_characteristics(self, uuids=[]): 85 return await self.client.discover_characteristics(uuids, self) 86 87 def get_characteristics_by_uuid(self, uuid): 88 return self.client.get_characteristics_by_uuid(uuid, self) 89 90 def __str__(self): 91 return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})' 92 93 94class CharacteristicProxy(AttributeProxy): 95 def __init__(self, client, handle, end_group_handle, uuid, properties): 96 super().__init__(client, handle, end_group_handle, uuid) 97 self.uuid = uuid 98 self.properties = properties 99 self.descriptors = [] 100 self.descriptors_discovered = False 101 102 def get_descriptor(self, descriptor_type): 103 for descriptor in self.descriptors: 104 if descriptor.type == descriptor_type: 105 return descriptor 106 107 async def discover_descriptors(self): 108 return await self.client.discover_descriptors(self) 109 110 async def subscribe(self, subscriber=None): 111 return await self.client.subscribe(self, subscriber) 112 113 def __str__(self): 114 return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})' 115 116 117class DescriptorProxy(AttributeProxy): 118 def __init__(self, client, handle, descriptor_type): 119 super().__init__(client, handle, 0, descriptor_type) 120 121 def __str__(self): 122 return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})' 123 124 125class ProfileServiceProxy: 126 ''' 127 Base class for profile-specific service proxies 128 ''' 129 @classmethod 130 def from_client(cls, client): 131 return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID) 132 133 134# ----------------------------------------------------------------------------- 135# GATT Client 136# ----------------------------------------------------------------------------- 137class Client: 138 def __init__(self, connection): 139 self.connection = connection 140 self.mtu = ATT_DEFAULT_MTU 141 self.mtu_exchange_done = False 142 self.request_semaphore = asyncio.Semaphore(1) 143 self.pending_request = None 144 self.pending_response = None 145 self.notification_subscribers = {} # Notification subscribers, by attribute handle 146 self.indication_subscribers = {} # Indication subscribers, by attribute handle 147 self.services = [] 148 149 def send_gatt_pdu(self, pdu): 150 self.connection.send_l2cap_pdu(ATT_CID, pdu) 151 152 async def send_command(self, command): 153 logger.debug(f'GATT Command from client: [0x{self.connection.handle:04X}] {command}') 154 self.send_gatt_pdu(command.to_bytes()) 155 156 async def send_request(self, request): 157 logger.debug(f'GATT Request from client: [0x{self.connection.handle:04X}] {request}') 158 159 # Wait until we can send (only one pending command at a time for the connection) 160 response = None 161 async with self.request_semaphore: 162 assert(self.pending_request is None) 163 assert(self.pending_response is None) 164 165 # Create a future value to hold the eventual response 166 self.pending_response = asyncio.get_running_loop().create_future() 167 self.pending_request = request 168 169 try: 170 self.send_gatt_pdu(request.to_bytes()) 171 response = await asyncio.wait_for(self.pending_response, GATT_REQUEST_TIMEOUT) 172 except asyncio.TimeoutError: 173 logger.warning(color('!!! GATT Request timeout', 'red')) 174 raise TimeoutError(f'GATT timeout for {request.name}') 175 finally: 176 self.pending_request = None 177 self.pending_response = None 178 179 return response 180 181 def send_confirmation(self, confirmation): 182 logger.debug(f'GATT Confirmation from client: [0x{self.connection.handle:04X}] {confirmation}') 183 self.send_gatt_pdu(confirmation.to_bytes()) 184 185 async def request_mtu(self, mtu): 186 # Check the range 187 if mtu < ATT_DEFAULT_MTU: 188 raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}') 189 if mtu > 0xFFFF: 190 raise ValueError('MTU must be <= 0xFFFF') 191 192 # We can only send one request per connection 193 if self.mtu_exchange_done: 194 return 195 196 # Send the request 197 self.mtu_exchange_done = True 198 response = await self.send_request(ATT_Exchange_MTU_Request(client_rx_mtu = mtu)) 199 if response.op_code == ATT_ERROR_RESPONSE: 200 raise ProtocolError( 201 response.error_code, 202 'att', 203 ATT_PDU.error_name(response.error_code), 204 response 205 ) 206 207 self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu) 208 return self.mtu 209 210 def get_services_by_uuid(self, uuid): 211 return [service for service in self.services if service.uuid == uuid] 212 213 def get_characteristics_by_uuid(self, uuid, service = None): 214 services = [service] if service else self.services 215 return [c for c in [c for s in services for c in s.characteristics] if c.uuid == uuid] 216 217 def on_service_discovered(self, service): 218 ''' Add a service to the service list if it wasn't already there ''' 219 already_known = False 220 for existing_service in self.services: 221 if existing_service.handle == service.handle: 222 already_known = True 223 break 224 if not already_known: 225 self.services.append(service) 226 227 async def discover_services(self, uuids = None): 228 ''' 229 See Vol 3, Part G - 4.4.1 Discover All Primary Services 230 ''' 231 starting_handle = 0x0001 232 services = [] 233 while starting_handle < 0xFFFF: 234 response = await self.send_request( 235 ATT_Read_By_Group_Type_Request( 236 starting_handle = starting_handle, 237 ending_handle = 0xFFFF, 238 attribute_group_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE 239 ) 240 ) 241 if response is None: 242 # TODO raise appropriate exception 243 return [] 244 245 # Check if we reached the end of the iteration 246 if response.op_code == ATT_ERROR_RESPONSE: 247 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 248 # Unexpected end 249 logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}') 250 # TODO raise appropriate exception 251 return 252 break 253 254 for attribute_handle, end_group_handle, attribute_value in response.attributes: 255 if attribute_handle < starting_handle or end_group_handle < attribute_handle: 256 # Something's not right 257 logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}') 258 return 259 260 # Create a service proxy for this service 261 service = ServiceProxy( 262 self, 263 attribute_handle, 264 end_group_handle, 265 UUID.from_bytes(attribute_value), 266 True 267 ) 268 269 # Filter out returned services based on the given uuids list 270 if (not uuids) or (service.uuid in uuids): 271 services.append(service) 272 273 # Add the service to the peer's service list 274 self.on_service_discovered(service) 275 276 # Stop if for some reason the list was empty 277 if not response.attributes: 278 break 279 280 # Move on to the next chunk 281 starting_handle = response.attributes[-1][1] + 1 282 283 return services 284 285 async def discover_service(self, uuid): 286 ''' 287 See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID 288 ''' 289 290 # Force uuid to be a UUID object 291 if type(uuid) is str: 292 uuid = UUID(uuid) 293 294 starting_handle = 0x0001 295 services = [] 296 while starting_handle < 0xFFFF: 297 response = await self.send_request( 298 ATT_Find_By_Type_Value_Request( 299 starting_handle = starting_handle, 300 ending_handle = 0xFFFF, 301 attribute_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, 302 attribute_value = uuid.to_pdu_bytes() 303 ) 304 ) 305 if response is None: 306 # TODO raise appropriate exception 307 return [] 308 309 # Check if we reached the end of the iteration 310 if response.op_code == ATT_ERROR_RESPONSE: 311 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 312 # Unexpected end 313 logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}') 314 # TODO raise appropriate exception 315 return 316 break 317 318 for attribute_handle, end_group_handle in response.handles_information: 319 if attribute_handle < starting_handle or end_group_handle < attribute_handle: 320 # Something's not right 321 logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}') 322 return 323 324 # Create a service proxy for this service 325 service = ServiceProxy(self, attribute_handle, end_group_handle, uuid, True) 326 327 # Add the service to the peer's service list 328 services.append(service) 329 self.on_service_discovered(service) 330 331 # Check if we've reached the end already 332 if end_group_handle == 0xFFFF: 333 break 334 335 # Stop if for some reason the list was empty 336 if not response.handles_information: 337 break 338 339 # Move on to the next chunk 340 starting_handle = response.handles_information[-1][1] + 1 341 342 return services 343 344 async def discover_included_services(self, service): 345 ''' 346 See Vol 3, Part G - 4.5.1 Find Included Services 347 ''' 348 # TODO 349 return [] 350 351 async def discover_characteristics(self, uuids, service): 352 ''' 353 See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2 Discover Characteristics by UUID 354 ''' 355 356 # Cast the UUIDs type from string to object if needed 357 uuids = [UUID(uuid) if type(uuid) is str else uuid for uuid in uuids] 358 359 # Decide which services to discover for 360 services = [service] if service else self.services 361 362 # Perform characteristic discovery for each service 363 discovered_characteristics = [] 364 for service in services: 365 starting_handle = service.handle 366 ending_handle = service.end_group_handle 367 368 characteristics = [] 369 while starting_handle <= ending_handle: 370 response = await self.send_request( 371 ATT_Read_By_Type_Request( 372 starting_handle = starting_handle, 373 ending_handle = ending_handle, 374 attribute_type = GATT_CHARACTERISTIC_ATTRIBUTE_TYPE 375 ) 376 ) 377 if response is None: 378 # TODO raise appropriate exception 379 return [] 380 381 # Check if we reached the end of the iteration 382 if response.op_code == ATT_ERROR_RESPONSE: 383 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 384 # Unexpected end 385 logger.warning(f'!!! unexpected error while discovering characteristics: {HCI_Constant.error_name(response.error_code)}') 386 # TODO raise appropriate exception 387 return 388 break 389 390 # Stop if for some reason the list was empty 391 if not response.attributes: 392 break 393 394 # Process all characteristics returned in this iteration 395 for attribute_handle, attribute_value in response.attributes: 396 if attribute_handle < starting_handle: 397 # Something's not right 398 logger.warning(f'bogus handle value: {attribute_handle}') 399 return [] 400 401 properties, handle = struct.unpack_from('<BH', attribute_value) 402 characteristic_uuid = UUID.from_bytes(attribute_value[3:]) 403 characteristic = CharacteristicProxy(self, handle, 0, characteristic_uuid, properties) 404 405 # Set the previous characteristic's end handle 406 if characteristics: 407 characteristics[-1].end_group_handle = attribute_handle - 1 408 409 characteristics.append(characteristic) 410 411 # Move on to the next characteristics 412 starting_handle = response.attributes[-1][0] + 1 413 414 # Set the end handle for the last characteristic 415 if characteristics: 416 characteristics[-1].end_group_handle = service.end_group_handle 417 418 # Set the service's characteristics 419 characteristics = [c for c in characteristics if not uuids or c.uuid in uuids] 420 service.characteristics = characteristics 421 discovered_characteristics.extend(characteristics) 422 423 return discovered_characteristics 424 425 async def discover_descriptors(self, characteristic = None, start_handle = None, end_handle = None): 426 ''' 427 See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors 428 ''' 429 if characteristic: 430 starting_handle = characteristic.handle + 1 431 ending_handle = characteristic.end_group_handle 432 elif start_handle and end_handle: 433 starting_handle = start_handle 434 ending_handle = end_handle 435 else: 436 return [] 437 438 descriptors = [] 439 while starting_handle <= ending_handle: 440 response = await self.send_request( 441 ATT_Find_Information_Request( 442 starting_handle = starting_handle, 443 ending_handle = ending_handle 444 ) 445 ) 446 if response is None: 447 # TODO raise appropriate exception 448 return [] 449 450 # Check if we reached the end of the iteration 451 if response.op_code == ATT_ERROR_RESPONSE: 452 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 453 # Unexpected end 454 logger.warning(f'!!! unexpected error while discovering descriptors: {HCI_Constant.error_name(response.error_code)}') 455 # TODO raise appropriate exception 456 return [] 457 break 458 459 # Stop if for some reason the list was empty 460 if not response.information: 461 break 462 463 # Process all descriptors returned in this iteration 464 for attribute_handle, attribute_uuid in response.information: 465 if attribute_handle < starting_handle: 466 # Something's not right 467 logger.warning(f'bogus handle value: {attribute_handle}') 468 return [] 469 470 descriptor = DescriptorProxy(self, attribute_handle, UUID.from_bytes(attribute_uuid)) 471 descriptors.append(descriptor) 472 # TODO: read descriptor value 473 474 # Move on to the next descriptor 475 starting_handle = response.information[-1][0] + 1 476 477 # Set the characteristic's descriptors 478 if characteristic: 479 characteristic.descriptors = descriptors 480 481 return descriptors 482 483 async def discover_attributes(self): 484 ''' 485 Discover all attributes, regardless of type 486 ''' 487 starting_handle = 0x0001 488 ending_handle = 0xFFFF 489 attributes = [] 490 while True: 491 response = await self.send_request( 492 ATT_Find_Information_Request( 493 starting_handle = starting_handle, 494 ending_handle = ending_handle 495 ) 496 ) 497 if response is None: 498 return [] 499 500 # Check if we reached the end of the iteration 501 if response.op_code == ATT_ERROR_RESPONSE: 502 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 503 # Unexpected end 504 logger.warning(f'!!! unexpected error while discovering attributes: {HCI_Constant.error_name(response.error_code)}') 505 return [] 506 break 507 508 for attribute_handle, attribute_uuid in response.information: 509 if attribute_handle < starting_handle: 510 # Something's not right 511 logger.warning(f'bogus handle value: {attribute_handle}') 512 return [] 513 514 attribute = AttributeProxy(self, attribute_handle, 0, UUID.from_bytes(attribute_uuid)) 515 attributes.append(attribute) 516 517 # Move on to the next attributes 518 starting_handle = attributes[-1].handle + 1 519 520 return attributes 521 522 async def subscribe(self, characteristic, subscriber=None): 523 # If we haven't already discovered the descriptors for this characteristic, do it now 524 if not characteristic.descriptors_discovered: 525 await self.discover_descriptors(characteristic) 526 527 # Look for the CCCD descriptor 528 cccd = characteristic.get_descriptor(GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR) 529 if not cccd: 530 logger.warning('subscribing to characteristic with no CCCD descriptor') 531 return 532 533 # Set the subscription bits and select the subscriber set 534 bits = 0 535 subscriber_sets = [] 536 if characteristic.properties & Characteristic.NOTIFY: 537 bits |= 0x0001 538 subscriber_sets.append(self.notification_subscribers.setdefault(characteristic.handle, set())) 539 if characteristic.properties & Characteristic.INDICATE: 540 bits |= 0x0002 541 subscriber_sets.append(self.indication_subscribers.setdefault(characteristic.handle, set())) 542 543 # Add subscribers to the sets 544 for subscriber_set in subscriber_sets: 545 if subscriber is not None: 546 subscriber_set.add(subscriber) 547 subscriber_set.add(lambda value: characteristic.emit('update', self.connection, value)) 548 549 await self.write_value(cccd, struct.pack('<H', bits), with_response=True) 550 551 async def read_value(self, attribute, no_long_read=False): 552 ''' 553 See Vol 3, Part G - 4.8.1 Read Characteristic Value 554 555 `attribute` can be an Attribute object, or a handle value 556 ''' 557 558 # Send a request to read 559 attribute_handle = attribute if type(attribute) is int else attribute.handle 560 response = await self.send_request(ATT_Read_Request(attribute_handle = attribute_handle)) 561 if response is None: 562 raise TimeoutError('read timeout') 563 if response.op_code == ATT_ERROR_RESPONSE: 564 raise ProtocolError( 565 response.error_code, 566 'att', 567 ATT_PDU.error_name(response.error_code), 568 response 569 ) 570 571 # If the value is the max size for the MTU, try to read more unless the caller 572 # specifically asked not to do that 573 attribute_value = response.attribute_value 574 if not no_long_read and len(attribute_value) == self.mtu - 1: 575 logger.debug('using READ BLOB to get the rest of the value') 576 offset = len(attribute_value) 577 while True: 578 response = await self.send_request( 579 ATT_Read_Blob_Request(attribute_handle = attribute_handle, value_offset = offset) 580 ) 581 if response is None: 582 raise TimeoutError('read timeout') 583 if response.op_code == ATT_ERROR_RESPONSE: 584 if response.error_code == ATT_ATTRIBUTE_NOT_LONG_ERROR or response.error_code == ATT_INVALID_OFFSET_ERROR: 585 break 586 raise ProtocolError( 587 response.error_code, 588 'att', 589 ATT_PDU.error_name(response.error_code), 590 response 591 ) 592 593 part = response.part_attribute_value 594 attribute_value += part 595 596 if len(part) < self.mtu - 1: 597 break 598 599 offset += len(part) 600 601 # Return the value as bytes 602 return attribute_value 603 604 async def read_characteristics_by_uuid(self, uuid, service): 605 ''' 606 See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID 607 ''' 608 609 if service is None: 610 starting_handle = 0x0001 611 ending_handle = 0xFFFF 612 else: 613 starting_handle = service.handle 614 ending_handle = service.end_group_handle 615 616 characteristics_values = [] 617 while starting_handle <= ending_handle: 618 response = await self.send_request( 619 ATT_Read_By_Type_Request( 620 starting_handle = starting_handle, 621 ending_handle = ending_handle, 622 attribute_type = uuid 623 ) 624 ) 625 if response is None: 626 # TODO raise appropriate exception 627 return [] 628 629 # Check if we reached the end of the iteration 630 if response.op_code == ATT_ERROR_RESPONSE: 631 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: 632 # Unexpected end 633 logger.warning(f'!!! unexpected error while reading characteristics: {HCI_Constant.error_name(response.error_code)}') 634 # TODO raise appropriate exception 635 return [] 636 break 637 638 # Stop if for some reason the list was empty 639 if not response.attributes: 640 break 641 642 # Process all characteristics returned in this iteration 643 for attribute_handle, attribute_value in response.attributes: 644 if attribute_handle < starting_handle: 645 # Something's not right 646 logger.warning(f'bogus handle value: {attribute_handle}') 647 return [] 648 649 characteristics_values.append(attribute_value) 650 651 # Move on to the next characteristics 652 starting_handle = response.attributes[-1][0] + 1 653 654 return characteristics_values 655 656 async def write_value(self, attribute, value, with_response=False): 657 ''' 658 See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic Value 659 660 `attribute` can be an Attribute object, or a handle value 661 ''' 662 663 # Send a request or command to write 664 attribute_handle = attribute if type(attribute) is int else attribute.handle 665 if with_response: 666 response = await self.send_request( 667 ATT_Write_Request( 668 attribute_handle = attribute_handle, 669 attribute_value = value 670 ) 671 ) 672 if response.op_code == ATT_ERROR_RESPONSE: 673 raise ProtocolError( 674 response.error_code, 675 'att', 676 ATT_PDU.error_name(response.error_code), response 677 ) 678 else: 679 await self.send_command( 680 ATT_Write_Command( 681 attribute_handle = attribute_handle, 682 attribute_value = value 683 ) 684 ) 685 686 def on_gatt_pdu(self, att_pdu): 687 logger.debug(f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}') 688 if att_pdu.op_code in ATT_RESPONSES: 689 if self.pending_request is None: 690 # Not expected! 691 logger.warning('!!! unexpected response, there is no pending request') 692 return 693 694 # Sanity check: the response should match the pending request unless it is an error response 695 if att_pdu.op_code != ATT_ERROR_RESPONSE: 696 expected_response_name = self.pending_request.name.replace('_REQUEST', '_RESPONSE') 697 if att_pdu.name != expected_response_name: 698 logger.warning(f'!!! mismatched response: expected {expected_response_name}') 699 return 700 701 # Return the response to the coroutine that is waiting for it 702 self.pending_response.set_result(att_pdu) 703 else: 704 handler_name = f'on_{att_pdu.name.lower()}' 705 handler = getattr(self, handler_name, None) 706 if handler is not None: 707 handler(att_pdu) 708 else: 709 logger.warning(f'{color(f"--- Ignoring GATT Response from [0x{self.connection.handle:04X}]:", "red")} {att_pdu}') 710 711 def on_att_handle_value_notification(self, notification): 712 # Call all subscribers 713 subscribers = self.notification_subscribers.get(notification.attribute_handle, []) 714 if not subscribers: 715 logger.warning('!!! received notification with no subscriber') 716 for subscriber in subscribers: 717 subscriber(notification.attribute_value) 718 719 def on_att_handle_value_indication(self, indication): 720 # Call all subscribers 721 subscribers = self.indication_subscribers.get(indication.attribute_handle, []) 722 if not subscribers: 723 logger.warning('!!! received indication with no subscriber') 724 for subscriber in subscribers: 725 subscriber(indication.attribute_value) 726 727 # Confirm that we received the indication 728 self.send_confirmation(ATT_Handle_Value_Confirmation()) 729