Source code for whad.ble.profile.characteristic

"""
BLE GATT Characteristic Model
=============================
"""
from re import I
from struct import pack, unpack
from typing import Union, Type, Optional, List, Iterator

from whad.ble.stack.att.constants import BleAttProperties, SecurityAccess
from whad.ble.profile.attribute import Attribute, UUID, get_uuid_alias
from whad.ble.exceptions import InvalidHandleValueException
from whad.ble.utils.clues import CluesDb

class desc_type:

    def __init__(self, uuid: UUID):
        self.__uuid = uuid

    def __call__(self, cls):
        Descriptor.register_type(self.__uuid, cls)
        return cls

class Properties:
    """Generic characteristic properties.
    """
    BROADCAST = 0x01
    READ = 0x02
    WRITE_WITHOUT_RESPONSE = 0x04
    WRITE = 0x08
    NOTIFY = 0x10
    INDICATE = 0x20
    AUTH_SIGNED_WRITES = 0x40
    EXTENDED_PROPERTIES = 0x80

class CharacteristicProperties(Properties):
    """Old name, defined for compatibility"""

[docs] class Descriptor(Attribute): """BLE Characteristic descriptor """ desc_types = {}
[docs] def __init__(self, uuid: UUID, handle: int = 0, value: bytes = b'', characteristic: Optional['Characteristic'] = None): super().__init__(uuid=uuid,handle=handle,value=value) self.__characteristic = characteristic
@property def characteristic(self) -> Optional['Characteristic']: """Parent characteristic """ return self.__characteristic @characteristic.setter def characteristic(self, charac: 'Characteristic'): """Set characteristic.""" self.__characteristic = charac @property def uuid(self): """Descriptor UUID """ return self.type_uuid @property def name(self): """Descriptor name """ # Search Bluetooth known UUIDs alias = get_uuid_alias(self.type_uuid) if alias is not None: return f"{alias} (0x{self.type_uuid})" # No alias return str(self.type_uuid)
[docs] @staticmethod def register_type(uuid: UUID, cls): """Register descriptor type (associate a descriptor UUID with the corresponding Python class (must inherit from Descriptor) """ uuid_value = uuid.value() if uuid_value not in Descriptor.desc_types: if issubclass(cls, Descriptor): Descriptor.desc_types[uuid_value] = cls
[docs] @staticmethod def get_type_uuid(desc_cls) -> Optional[UUID]: """Find the type UUID corresponding to a given registered descriptor's class. :return: Descriptor's type UUID if found, `None` otherwise. :rtype: UUID """ for desc_type,desc_cls in Descriptor.desc_types.items(): if desc_cls == desc_cls: return UUID(desc_type) # Not found return None
[docs] @staticmethod def from_uuid(characteristic, handle: int, uuid: UUID, value: bytes): """Create an instance of a descriptor based on the provided UUID and descriptor value. :param uuid: Descriptor UUID :type uuid: UUID :param value: Descriptor value :type value: bytes :return: Instance of the corresponding descriptor :rtype: Descriptor """ uuid_value = uuid.value() if uuid_value in Descriptor.desc_types: cls = Descriptor.desc_types[uuid_value] return cls.from_value(characteristic, handle, value) # Cannot find any class matching the provided UUID, return a generic # descriptor. return Descriptor(uuid, handle, value, characteristic)
@classmethod def _build(cls, instance: Type['Descriptor']): """Build a new descriptor based on current template.""" return cls( instance.uuid, 0, instance.value, characteristic=instance.characteristic ) def build(self): return self.__class__._build(self)
class CharacteristicDescriptor(Descriptor): """Old class defining a characteristic descriptor, kept for backward compatibility. .. deprecated:: 1.3.0 Renamed for clarity purpose ('Characteristic' in this class name has been removed as this class is already defined in the *characteristic* module), please use the new :class:`~whad.ble.profile.characteristic.Descriptor` class instead. """
[docs] @desc_type(UUID(0x2902)) class ClientCharacteristicConfig(Descriptor): """Client Characteristic Configuration Descriptor """
[docs] def __init__(self, handle: int = 0, notify: bool = False, indicate: bool = False, characteristic: Optional['Characteristic'] = None): """Instantiate a Client Characteristic Configuration Descriptor :param bool notify: Set to True to get the corresponding characteristic notified on change :param bool indicate: Set to True to get the corresponding characteristic indicated on change """ value = 0 if notify: value |= 0x0001 if indicate: value |= 0x0002 super().__init__(UUID(0x2902), handle=handle, value=pack('<H', value), characteristic=characteristic)
@property def config(self): """CCCD configuration """ return unpack('<H', super().value)[0] @config.setter def config(self, val): """Update CCCD configuration """ super().value = pack('<H', val)
[docs] @staticmethod def from_value(characteristic, handle, value): """Create a ClientCharacteristicConfig instance from the provided handle and value, and tie it to a specific characteristic. """ # Create our CCCD object if len(value) >= 1: v = value[0] else: v = 0x00 cccd = ClientCharacteristicConfig(handle,(v & 0x0001) != 0, (v & 0x0002) != 0, characteristic) # Set its value cccd.value = value return cccd
@classmethod def _build(cls, instance: 'ClientCharacteristicConfig'): """Build a new descriptor based on current template.""" return cls( 0, (instance.config & 0x0001) != 0, (instance.config & 0x0002) != 0, characteristic=instance.characteristic )
class ReportReference(Descriptor): """Report Reference descriptor, used in HID profile """ def __init__(self, handle: int = 0, characteristic: Optional['Characteristic'] = None): """Instantiate a Report Reference Descriptor :param bool notify: Set to True to get the corresponding characteristic notified on change :param bool indicate: Set to True to get the corresponding characteristic indicated on change """ super().__init__( uuid=UUID(0x2908), handle=handle, value=b'\x01\x01', characteristic=characteristic ) @classmethod def _build(cls, instance: 'ReportReference'): """Build a new descriptor based on current template.""" return cls( 0, characteristic=instance.characteristic ) class ReportReferenceDescriptor(ReportReference): """Old report reference descriptor class, kept for backward compatibility. .. deprecated:: 1.3.0 Use the new :class:`~whad.ble.profile.characteristic.ReportReference` class that defines an HID ReportReference descriptor. """
[docs] @desc_type(UUID(0x2901)) class UserDescription(Descriptor): """Characteristic user description descriptor. This descriptor specifies a textual description of the characteristic it is attached to. This description is exposed by the GATT server and can be read by a GATT client. """
[docs] def __init__(self, handle: int = 0, description: str = '', characteristic: Optional['Characteristic'] = None): """Instantiate a Characteristic User Description descriptor :param description: Set characteristic text description :type description: str :param characteristic: Characteristic this descriptor is attached to, not required when defining a GATT model. :type characteristic: Characteristic, optional """ self.__description = description super().__init__( uuid=UUID(0x2901), handle=handle, value=description.encode('utf-8'), characteristic=characteristic )
@property def text(self) -> str: """User description """ return self.__description @property def name(self): """Descriptor name Clean description as it may contain null chars. """ # End string at the first null char encountered if '\x00' in self.__description: desc = self.__description[:self.__description.index('\x00')] else: desc = self.__description return f"{super().name}, '{desc}'"
[docs] @staticmethod def from_value(characteristic, handle, value): """Create CUD descriptor from value """ return UserDescription( handle, description=value.decode('utf-8'), characteristic=characteristic )
@classmethod def _build(cls, instance: 'UserDescription'): """Build a new descriptor based on current template.""" return cls( 0, instance.value.decode('utf-8'), characteristic=instance.characteristic )
class CharacteristicUserDescriptionDescriptor(UserDescription): """Old name of the UserDescription descriptor.""" class CharacteristicValue(Attribute): """Characteristic value attribute. """ def __init__(self, uuid, handle: int = 0, value: bytes = b'', characteristic: Optional['Characteristic'] = None): super().__init__(uuid=uuid, handle=handle, value=value) self.__characteristic = characteristic @property def characteristic(self): """Associated characteristic """ return self.__characteristic @property def uuid(self): """Attribute UUID """ return self.type_uuid
[docs] class Characteristic(Attribute): """BLE Characteristic """ # Properties BROADCAST = 0x01 READ = 0x02 WRITE_WITHOUT_RESPONSE = 0x04 WRITE = 0x08 NOTIFY = 0x10 INDICATE = 0x20 AUTH_SIGNED_WRITES = 0x40 EXTENDED_PROPERTIES = 0x80
[docs] def __init__(self, uuid: UUID, handle: int = 0, end_handle: int = 0, value: bytes = b'', properties: int = 0, permissions: Optional[List[str]] = None, notify: bool = False, indicate: bool = False, required: bool = False, description: Optional[str] = None, security: Optional[SecurityAccess] = None, descriptors: List[Descriptor] = []): """Instantiate a BLE characteristic object :param uuid: 16-bit or 128-bit UUID :param int handle: Handle value :param bytes value: Characteristic value :param int perms: Permissions """ # Create our parent attribute (type UUID of 0x2803, and value containing our properties, value handle and UUID) super().__init__(uuid=UUID(0x2803), handle=handle, value=pack('<BH', properties & 0xff, handle+1)+uuid.to_bytes()) # Set end handle: if 0, then it is considered as not set by the caller and set by # default to the characteristic's handle value. if end_handle == 0: self.__end_handle = handle else: self.__end_handle = end_handle # Set characteristic UUID self.__charac_uuid = uuid # Set characteristic alias self.__alias = None # Notification and indication callbacks self.__notification_callback = None self.__indication_callback = None # Add a characteristic value attribute self.__value_handle = handle + 1 self.__value = CharacteristicValue(uuid, self.__value_handle, value, self) self.__end_handle = self.__value_handle # Set characteristic properties self.__properties = properties # Update properties according to defined permissions, if any (only used when object is considered a template) if permissions is not None: perms = list(map(lambda x: x.lower().strip(), permissions)) if 'read' in perms: self.__properties |= Characteristic.READ if 'write' in perms: self.__properties |= Characteristic.WRITE if 'write_without_response' in perms: self.__properties |= Characteristic.WRITE_WITHOUT_RESPONSE if 'notify' in perms: self.__properties |= Characteristic.NOTIFY if 'indicate' in perms: self.__properties |= Characteristic.INDICATE # If characteristic is set as supporting notifications, define the correct property. if notify: self.__properties |= Properties.NOTIFY # Same for indications. if indicate: self.__properties |= Properties.INDICATE # By default, this characteristic object is instantiated as a standalone characteristic, # this means all the handles depending on this characteristic (value and descriptors) will # be computed from the characteristic's definition handle. self.__service = None # When `required` is set, the characteristic is set as *mandatory*. This flag is only used by a # service to determine if this characteristic must be present in its parent service. self.required = required # Security properties self.__security = security if security is not None else [] # List of descriptors. self.__descriptors = [] # Add descriptors if handle is 0 (object used as a template) if handle == 0: # If this characteristic's handle is not set, we automatically add a ClientCharacteristicConfig # descriptor if its properties say it supports indication and/or notification. if (self.can_indicate() or self.can_notify()): self.add_descriptor(ClientCharacteristicConfig( characteristic=self )) # Add a CharacteristicUserDescriptionDescriptor if description is set and handle is 0 if (description is not None and handle == 0): self.add_descriptor(UserDescription( description=description, characteristic=self )) # Add additional descriptors for desc in descriptors: if isinstance(desc, Descriptor): # Bind descriptor to this characteristic desc.characteristic = self # Don't add a CCC descriptor if characteristic already has one. if isinstance(desc, ClientCharacteristicConfig) and self.get_descriptor(ClientCharacteristicConfig): continue # Add descriptor to our list of descriptors self.add_descriptor(desc)
@classmethod def _build(cls, instance): """Build a new characteristic object based on this model.""" # Create a new list of descriptors descriptors = [] for desc in instance.descriptors(): descriptors.append(desc.build()) # Create a new object based on current template. charac = cls( instance.uuid, handle=0, end_handle=0, value=instance.value, properties=instance.properties, security=instance.security, descriptors=descriptors ) if instance.alias is not None: charac.alias = instance.alias return charac
[docs] def build(self): """Build a new characteristic from current object.""" return self.__class__._build(self)
[docs] def payload(self): """Return characteristic payload """ return pack('<BH', self.__properties, self.__value_handle) + self.__value.uuid.packed
[docs] def set_notification_callback(self, callback): """Save the provided callback as notification callback """ self.__notification_callback = callback
[docs] def set_indication_callback(self, callback): """Save the provided callback as indication callback """ self.__indication_callback = callback
@Attribute.handle.setter def handle(self, new_handle): """Set new handle value Update the characteristic's value handle as well as handles of all descriptors attached to this characteristic. """ if isinstance(new_handle, int): # Set attribute handle Attribute.handle.fset(self, new_handle) self.__value.handle = new_handle + 1 self.__value_handle = new_handle + 1 handle = self.__value_handle # Update descriptors handle for descriptor in self.__descriptors: handle += 1 descriptor.handle = handle # Update end handle self.__end_handle = handle else: raise InvalidHandleValueException @property def value(self): return self.__value.value @value.setter def value(self, value): """Set characteristic value :param bytes new_value: Value """ self.__value.value = value # Notify or indicate if required if self.must_notify() and self.__notification_callback is not None: self.__notification_callback(self) elif self.must_indicate() and self.__indication_callback is not None: self.__indication_callback(self) @property def value_attr(self): """Associated value attribute """ return self.__value @property def value_handle(self): """Characteristic value handle """ return self.__value_handle @value_handle.setter def value_handle(self, value): """Update characteristic value handle """ self.__value_handle = value self.__value.handle = value @property def properties(self): """Characteristic properties """ return self.__properties @properties.setter def properties(self, new_properties): """Set characteristic properties """ self.__properties = new_properties @property def uuid(self) -> UUID: """Characteristic UUID """ return self.__charac_uuid @uuid.setter def uuid(self, value): """Update characteristic UUID """ self.__charac_uuid = value @property def end_handle(self): """Characteristic end handle """ return self.__end_handle @property def name(self): """Characteristic standard name (if any) """ # Search Bluetooth known UUIDs alias = get_uuid_alias(self.__charac_uuid) if alias is not None: return f"{alias} (0x{self.__charac_uuid})" # Search in collaborative CLUES database alias = CluesDb.get_uuid_alias(self.__charac_uuid) if alias is not None: if self.__charac_uuid.type == UUID.TYPE_16: return f"{alias} (0x{self.__charac_uuid})" else: return f"{alias} ({self.__charac_uuid})" return str(self.__charac_uuid) @property def service(self): """Service this characteristic belongs to.""" return self.__service @service.setter def service(self, service): """Set characteristic service.""" self.__service = service @property def alias(self) -> Optional[str]: """Characteristic alias.""" return self.__alias @alias.setter def alias(self, alias: str): self.__alias = alias ########################## # Methods ##########################
[docs] def attach(self, service): """Attach this characteristic to a service. :param service: Service referenced :type service: Service """ self.__service = service
def get_required_handles(self) -> int: if self.handle is not None and self.__end_handle is not None: return (self.__end_handle - self.handle) + 1 return 0
[docs] def readable(self): """Determine if characteristic can be read """ return (self.properties & Properties.READ) != 0
[docs] def writeable(self): """Determine if characteristic can be written to """ return ( ((self.properties & Properties.WRITE) != 0) or ((self.properties & Properties.WRITE_WITHOUT_RESPONSE) != 0) )
[docs] def can_notify(self) -> bool: """Determine if characteristic sends notifications. :return: ``True`` if characteristic sends notification, ``False`` otherwise. :rtype: bool """ return (self.properties & Properties.NOTIFY) != 0
[docs] def must_notify(self): """Determine if a notification must be sent for this characteristic. Notification must be sent when a characteristic has the notification property and its ClientCharacteristicConfiguration descriptor has notifications enabled. """ if (self.properties & Properties.NOTIFY) != 0: cccd = self.get_client_config() if cccd is not None: return cccd.config == 0x0001 return False
[docs] def can_indicate(self) -> bool: """Determine if characteristic sends indications. :return: ``True`` if characteristic sends indication, ``False`` otherwise. :rtype: bool """ return (self.properties & Properties.INDICATE) != 0
[docs] def must_indicate(self): """Determine if an indication must be sent for this characteristic. Indication must be sent when a characteristic has the indication property and its ClientCharacteristicConfiguration descriptor has indications enabled. """ if (self.properties & Properties.INDICATE) != 0: cccd = self.get_client_config() if cccd is not None: return cccd.config == 0x0002 return False
[docs] def add_descriptor(self, descriptor: Descriptor) -> 'Characteristic': """Add a descriptor :param descriptor: Descriptor instance to add to this characteristic. :type descriptor: :class:`whad.ble.profile.characteristic.Descriptor` """ # Set descriptor's handle and update end handle if descriptor.handle == 0: descriptor.handle = self.__end_handle + 1 # Add this descriptor to our list of descriptors self.__descriptors.append(descriptor) # Update our end handle self.__end_handle = max(descriptor.handle, self.__end_handle) return self
[docs] def get_descriptor(self, desc_type: Union[UUID, Type[Descriptor]]) -> Optional[Descriptor]: """Retrieve a decriptor based on its type UUID or class. :param desc_type: Descriptor type :type desc_type: UUID, :class:`whad.ble.profile.characteristic.Descriptor` :return: First matching descriptor belonging to this characteristic :rtype: :class:`whad.ble.profile.Descriptor`, optional """ # Validate descriptor type (UUID or class) if isinstance(desc_type, UUID): # Descriptor's type UUID is provided, use it as-is type_uuid = desc_type elif issubclass(desc_type, Descriptor): # Descriptor class provided, search for corresponding type UUID for desc in self.__descriptors: if isinstance(desc, desc_type): return desc return None # If we found a valid type UUID, look for a matching descriptor for desc in self.__descriptors: if desc.type_uuid == type_uuid: return desc # Not found return None
[docs] def descriptors(self) -> Iterator[Descriptor]: """Iterate over the registered descriptors (generator) """ yield from self.__descriptors
[docs] def get_client_config(self): """Find characteristic client configuration descriptor """ for desc in self.__descriptors: if isinstance(desc, ClientCharacteristicConfig): return desc return None
@property def security(self) -> SecurityAccess: """Returns security access property """ return self.__security
[docs] def get_security_access(self, access_type): """Returns the security access properties linked to an access type. :param access_type: access type to check :type access_type: SecurityAccess """ for access in self.__security: if isinstance(access, access_type): return access return None
[docs] def check_security_property(self, access_type, prop): """Returns a boolean indicating if a property is required for a given access type. :param access_type: access type to check :type access_type: SecurityAccess :param property: security property to check :type property: SecurityProperty """ access = self.get_security_access(access_type) if access is not None: return prop in access.access return False