"""
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