diff --git a/.vscode/settings.json b/.vscode/settings.json index c6696abb..a68eba85 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -104,5 +104,8 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python-envs.defaultEnvManager": "ms-python.python:system", - "python-envs.pythonProjects": [] + "python-envs.pythonProjects": [], + "nrf-connect.applications": [ + "${workspaceFolder}/extras/zephyr/hci_usb" + ] } diff --git a/apps/auracast.py b/apps/auracast.py index 19bff3d4..1e7413c6 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -39,7 +39,7 @@ import bumble.device import bumble.logging import bumble.transport import bumble.utils -from bumble import company_ids, core, gatt, hci +from bumble import company_ids, core, data_types, gatt, hci from bumble.audio import io as audio_io from bumble.colors import color from bumble.profiles import bap, bass, le_audio, pbp @@ -859,21 +859,13 @@ async def run_transmit( ) broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id) - advertising_manufacturer_data = ( - b'' - if manufacturer_data is None - else bytes( - core.AdvertisingData( - [ - ( - core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA, - struct.pack('>> {color(address, address_color)} ' f'[{color(address_type_string, type_color)}]{address_qualifier}' f'{resolution_qualifier}:{separator}' f'{phy_info}' f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}' - f'{advertisement.data.to_string(separator)}\n' + f'{details}\n' ) def on_advertisement(self, advertisement): diff --git a/bumble/avrcp.py b/bumble/avrcp.py index 5fe52d25..6aed3496 100644 --- a/bumble/avrcp.py +++ b/bumble/avrcp.py @@ -21,11 +21,12 @@ import asyncio import enum import logging import struct -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import ( AsyncIterator, Awaitable, Callable, + ClassVar, Iterable, List, Optional, @@ -36,7 +37,7 @@ from typing import ( cast, ) -from bumble import avc, avctp, core, l2cap, utils +from bumble import avc, avctp, core, hci, l2cap, utils from bumble.colors import color from bumble.device import Connection, Device from bumble.sdp import ( @@ -64,6 +65,96 @@ AVRCP_PID = 0x110E AVRCP_BLUETOOTH_SIG_COMPANY_ID = 0x001958 +class PduId(utils.OpenIntEnum): + GET_CAPABILITIES = 0x10 + LIST_PLAYER_APPLICATION_SETTING_ATTRIBUTES = 0x11 + LIST_PLAYER_APPLICATION_SETTING_VALUES = 0x12 + GET_CURRENT_PLAYER_APPLICATION_SETTING_VALUE = 0x13 + SET_PLAYER_APPLICATION_SETTING_VALUE = 0x14 + GET_PLAYER_APPLICATION_SETTING_ATTRIBUTE_TEXT = 0x15 + GET_PLAYER_APPLICATION_SETTING_VALUE_TEXT = 0x16 + INFORM_DISPLAYABLE_CHARACTER_SET = 0x17 + INFORM_BATTERY_STATUS_OF_CT = 0x18 + GET_ELEMENT_ATTRIBUTES = 0x20 + GET_PLAY_STATUS = 0x30 + REGISTER_NOTIFICATION = 0x31 + REQUEST_CONTINUING_RESPONSE = 0x40 + ABORT_CONTINUING_RESPONSE = 0x41 + SET_ABSOLUTE_VOLUME = 0x50 + SET_ADDRESSED_PLAYER = 0x60 + SET_BROWSED_PLAYER = 0x70 + GET_FOLDER_ITEMS = 0x71 + GET_TOTAL_NUMBER_OF_ITEMS = 0x75 + + +class CharacterSetId(hci.SpecableEnum): + UTF_8 = 0x06 + + +class MediaAttributeId(hci.SpecableEnum): + TITLE = 0x01 + ARTIST_NAME = 0x02 + ALBUM_NAME = 0x03 + TRACK_NUMBER = 0x04 + TOTAL_NUMBER_OF_TRACKS = 0x05 + GENRE = 0x06 + PLAYING_TIME = 0x07 + DEFAULT_COVER_ART = 0x08 + + +class PlayStatus(hci.SpecableEnum): + STOPPED = 0x00 + PLAYING = 0x01 + PAUSED = 0x02 + FWD_SEEK = 0x03 + REV_SEEK = 0x04 + ERROR = 0xFF + + +class EventId(hci.SpecableEnum): + PLAYBACK_STATUS_CHANGED = 0x01 + TRACK_CHANGED = 0x02 + TRACK_REACHED_END = 0x03 + TRACK_REACHED_START = 0x04 + PLAYBACK_POS_CHANGED = 0x05 + BATT_STATUS_CHANGED = 0x06 + SYSTEM_STATUS_CHANGED = 0x07 + PLAYER_APPLICATION_SETTING_CHANGED = 0x08 + NOW_PLAYING_CONTENT_CHANGED = 0x09 + AVAILABLE_PLAYERS_CHANGED = 0x0A + ADDRESSED_PLAYER_CHANGED = 0x0B + UIDS_CHANGED = 0x0C + VOLUME_CHANGED = 0x0D + + def __bytes__(self) -> bytes: + return bytes([int(self)]) + + +class StatusCode(hci.SpecableEnum): + INVALID_COMMAND = 0x00 + INVALID_PARAMETER = 0x01 + PARAMETER_CONTENT_ERROR = 0x02 + INTERNAL_ERROR = 0x03 + OPERATION_COMPLETED = 0x04 + UID_CHANGED = 0x05 + INVALID_DIRECTION = 0x07 + NOT_A_DIRECTORY = 0x08 + DOES_NOT_EXIST = 0x09 + INVALID_SCOPE = 0x0A + RANGE_OUT_OF_BOUNDS = 0x0B + FOLDER_ITEM_IS_NOT_PLAYABLE = 0x0C + MEDIA_IN_USE = 0x0D + NOW_PLAYING_LIST_FULL = 0x0E + SEARCH_NOT_SUPPORTED = 0x0F + SEARCH_IN_PROGRESS = 0x10 + INVALID_PLAYER_ID = 0x11 + PLAYER_NOT_BROWSABLE = 0x12 + PLAYER_NOT_ADDRESSED = 0x13 + NO_VALID_SEARCH_RESULTS = 0x14 + NO_AVAILABLE_PLAYERS = 0x15 + ADDRESSED_PLAYER_CHANGED = 0x16 + + # ----------------------------------------------------------------------------- def make_controller_service_sdp_records( service_record_handle: int, @@ -200,14 +291,52 @@ def make_target_service_sdp_records( # ----------------------------------------------------------------------------- -def _decode_attribute_value(value: bytes, character_set: CharacterSetId) -> str: - try: - if character_set == CharacterSetId.UTF_8: - return value.decode("utf-8") - return value.decode("ascii") - except UnicodeDecodeError: - logger.warning(f"cannot decode string with bytes: {value.hex()}") - return "" +@dataclass +class MediaAttribute: + attribute_id: MediaAttributeId + attribute_value: str + character_set_id: CharacterSetId = CharacterSetId.UTF_8 + + @classmethod + def _decode_attribute_value( + cls, value: bytes, character_set: CharacterSetId + ) -> str: + try: + if character_set == CharacterSetId.UTF_8: + return value.decode("utf-8") + return value.decode("ascii") + except UnicodeDecodeError: + logger.warning(f"cannot decode string with bytes: {value.hex()}") + return value.hex() + + @classmethod + def parse_from_bytes(cls, pdu: bytes, offset: int) -> tuple[int, MediaAttribute]: + ( + attribute_id_int, + character_set_id_int, + attribute_value_length, + ) = struct.unpack_from(">IHH", pdu, offset) + attribute_value_bytes = pdu[offset + 8 : offset + 8 + attribute_value_length] + character_set_id = CharacterSetId(character_set_id_int) + return offset + 8 + attribute_value_length, cls( + attribute_id=MediaAttributeId(attribute_id_int), + character_set_id=character_set_id, + attribute_value=cls._decode_attribute_value( + attribute_value_bytes, character_set_id + ), + ) + + def __bytes__(self) -> bytes: + attribute_value_bytes = self.attribute_value.encode("utf-8") + return ( + struct.pack( + ">IHH", + int(self.attribute_id), + int(self.character_set_id), + len(attribute_value_bytes), + ) + + attribute_value_bytes + ) # ----------------------------------------------------------------------------- @@ -218,10 +347,10 @@ class PduAssembler: 6.3.1 AVRCP specific AV//C commands """ - pdu_id: Optional[Protocol.PduId] + pdu_id: Optional[PduId] payload: bytes - def __init__(self, callback: Callable[[Protocol.PduId, bytes], None]) -> None: + def __init__(self, callback: Callable[[PduId, bytes], None]) -> None: self.callback = callback self.reset() @@ -230,7 +359,7 @@ class PduAssembler: self.parameter = b'' def on_pdu(self, pdu: bytes) -> None: - pdu_id = Protocol.PduId(pdu[0]) + pdu_id = PduId(pdu[0]) packet_type = Protocol.PacketType(pdu[1] & 3) parameter_length = struct.unpack_from('>H', pdu, 2)[0] parameter = pdu[4 : 4 + parameter_length] @@ -269,178 +398,151 @@ class PduAssembler: # ----------------------------------------------------------------------------- -@dataclass class Command: - pdu_id: Protocol.PduId - parameter: bytes + pdu_id: ClassVar[PduId] + _payload: Optional[bytes] = None - def to_string(self, properties: dict[str, str]) -> str: - properties_str = ",".join( - [f"{name}={value}" for name, value in properties.items()] - ) - return f"Command[{self.pdu_id.name}]({properties_str})" + _Command = TypeVar('_Command', bound='Command') + subclasses: ClassVar[dict[int, type[Command]]] = {} + fields: ClassVar[hci.Fields] = () - def __str__(self) -> str: - return self.to_string({"parameters": self.parameter.hex()}) + @classmethod + def command(cls, subclass: type[_Command]) -> type[_Command]: + cls.subclasses[subclass.pdu_id] = subclass + subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass) + return subclass - def __repr__(self) -> str: - return str(self) + @classmethod + def from_bytes(cls, pdu_id: int, pdu: bytes) -> Command: + if not (subclass := cls.subclasses.get(pdu_id)): + raise core.InvalidPacketError(f"Unimplemented PDU {pdu_id}") + instance = subclass(**hci.HCI_Object.dict_from_bytes(pdu, 0, subclass.fields)) + instance._payload = pdu[0:] + return instance + + def __bytes__(self) -> bytes: + if self._payload is None: + self._payload = hci.HCI_Object.dict_to_bytes(self.__dict__, self.fields) + return self._payload # ----------------------------------------------------------------------------- +@Command.command +@dataclass class GetCapabilitiesCommand(Command): - class CapabilityId(utils.OpenIntEnum): + pdu_id = PduId.GET_CAPABILITIES + + class CapabilityId(hci.SpecableEnum): COMPANY_ID = 0x02 EVENTS_SUPPORTED = 0x03 - capability_id: CapabilityId - - @classmethod - def from_bytes(cls, pdu: bytes) -> GetCapabilitiesCommand: - return cls(cls.CapabilityId(pdu[0])) - - def __init__(self, capability_id: CapabilityId) -> None: - super().__init__(Protocol.PduId.GET_CAPABILITIES, bytes([capability_id])) - self.capability_id = capability_id - - def __str__(self) -> str: - return self.to_string({"capability_id": self.capability_id.name}) + capability_id: CapabilityId = field(metadata=CapabilityId.type_metadata(1)) # ----------------------------------------------------------------------------- +@Command.command +@dataclass class GetPlayStatusCommand(Command): - @classmethod - def from_bytes(cls, _: bytes) -> GetPlayStatusCommand: - return cls() - - def __init__(self) -> None: - super().__init__(Protocol.PduId.GET_PLAY_STATUS, b'') + pdu_id = PduId.GET_PLAY_STATUS # ----------------------------------------------------------------------------- +@Command.command +@dataclass class GetElementAttributesCommand(Command): - identifier: int - attribute_ids: list[MediaAttributeId] + pdu_id = PduId.GET_ELEMENT_ATTRIBUTES - @classmethod - def from_bytes(cls, pdu: bytes) -> GetElementAttributesCommand: - identifier = struct.unpack_from(">Q", pdu)[0] - num_attributes = pdu[8] - attribute_ids = [MediaAttributeId(pdu[9 + i]) for i in range(num_attributes)] - return cls(identifier, attribute_ids) - - def __init__( - self, identifier: int, attribute_ids: Sequence[MediaAttributeId] - ) -> None: - parameter = struct.pack(">QB", identifier, len(attribute_ids)) + b''.join( - [struct.pack(">I", int(attribute_id)) for attribute_id in attribute_ids] - ) - super().__init__(Protocol.PduId.GET_ELEMENT_ATTRIBUTES, parameter) - self.identifier = identifier - self.attribute_ids = list(attribute_ids) - - -# ----------------------------------------------------------------------------- -class SetAbsoluteVolumeCommand(Command): - MAXIMUM_VOLUME = 0x7F - - volume: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeCommand: - return cls(pdu[0]) - - def __init__(self, volume: int) -> None: - super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume])) - self.volume = volume - - def __str__(self) -> str: - return self.to_string({"volume": str(self.volume)}) - - -# ----------------------------------------------------------------------------- -class RegisterNotificationCommand(Command): - event_id: EventId - playback_interval: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> RegisterNotificationCommand: - event_id = EventId(pdu[0]) - playback_interval = struct.unpack_from(">I", pdu, 1)[0] - return cls(event_id, playback_interval) - - def __init__(self, event_id: EventId, playback_interval: int) -> None: - super().__init__( - Protocol.PduId.REGISTER_NOTIFICATION, - struct.pack(">BI", int(event_id), playback_interval), - ) - self.event_id = event_id - self.playback_interval = playback_interval - - def __str__(self) -> str: - return self.to_string( + identifier: int = field( + metadata=hci.metadata( { - "event_id": self.event_id.name, - "playback_interval": str(self.playback_interval), + 'parser': lambda data, offset: ( + offset + 8, + int.from_bytes(data[offset : offset + 8], byteorder='big'), + ), + 'serializer': lambda x: x.to_bytes(8, byteorder='big'), } ) + ) + attribute_ids: Sequence[MediaAttributeId] = field( + metadata=MediaAttributeId.type_metadata(1, list_begin=True, list_end=True) + ) + + +# ----------------------------------------------------------------------------- +@Command.command +@dataclass +class SetAbsoluteVolumeCommand(Command): + pdu_id = PduId.SET_ABSOLUTE_VOLUME + MAXIMUM_VOLUME = 0x7F + + volume: int = field(metadata=hci.metadata(1)) + + +# ----------------------------------------------------------------------------- +@Command.command +@dataclass +class RegisterNotificationCommand(Command): + pdu_id = PduId.REGISTER_NOTIFICATION + + event_id: EventId = field(metadata=EventId.type_metadata(1)) + playback_interval: int = field(metadata=hci.metadata('>4')) + + +# ----------------------------------------------------------------------------- +class Response: + pdu_id: PduId + _payload: Optional[bytes] = None + + fields: ClassVar[hci.Fields] = () + + _Response = TypeVar('_Response', bound='Response') + + @classmethod + def register(cls, subclass: type[_Response]) -> type[_Response]: + subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass) + return subclass + + def __bytes__(self) -> bytes: + if self._payload is None: + self._payload = hci.HCI_Object.dict_to_bytes(self.__dict__, self.fields) + return self._payload + + @classmethod + def from_bytes(cls, pdu: bytes, pdu_id: Optional[PduId] = None) -> Response: + kwargs = hci.HCI_Object.dict_from_bytes(pdu, 0, cls.fields) + if pdu_id is not None: + kwargs['pdu_id'] = pdu_id + instance = cls(**kwargs) + instance._payload = pdu + return instance + + +# ----------------------------------------------------------------------------- +@Response.register +@dataclass +class RejectedResponse(Response): + pdu_id: PduId + status_code: StatusCode = field(metadata=StatusCode.type_metadata(1)) + + +# ----------------------------------------------------------------------------- +@Response.register +@dataclass +class NotImplementedResponse(Response): + pdu_id: PduId + parameters: bytes = field(metadata=hci.metadata('*')) # ----------------------------------------------------------------------------- @dataclass -class Response: - pdu_id: Protocol.PduId - parameter: bytes - - def to_string(self, properties: dict[str, str]) -> str: - properties_str = ",".join( - [f"{name}={value}" for name, value in properties.items()] - ) - return f"Response[{self.pdu_id.name}]({properties_str})" - - def __str__(self) -> str: - return self.to_string({"parameter": self.parameter.hex()}) - - def __repr__(self) -> str: - return str(self) - - -# ----------------------------------------------------------------------------- -class RejectedResponse(Response): - status_code: Protocol.StatusCode - - @classmethod - def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> RejectedResponse: - return cls(pdu_id, Protocol.StatusCode(pdu[0])) - - def __init__( - self, pdu_id: Protocol.PduId, status_code: Protocol.StatusCode - ) -> None: - super().__init__(pdu_id, bytes([int(status_code)])) - self.status_code = status_code - - def __str__(self) -> str: - return self.to_string( - { - "status_code": self.status_code.name, - } - ) - - -# ----------------------------------------------------------------------------- -class NotImplementedResponse(Response): - @classmethod - def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> NotImplementedResponse: - return cls(pdu_id, pdu[1:]) - - -# ----------------------------------------------------------------------------- class GetCapabilitiesResponse(Response): + pdu_id = PduId.GET_CAPABILITIES capability_id: GetCapabilitiesCommand.CapabilityId - capabilities: list[Union[SupportsBytes, bytes]] + capabilities: Sequence[Union[SupportsBytes, bytes]] @classmethod - def from_bytes(cls, pdu: bytes) -> GetCapabilitiesResponse: + def from_bytes(cls, pdu: bytes, pdu_id: Optional[PduId] = None) -> Response: + del pdu_id # Unused. if len(pdu) < 2: # Possibly a reject response. return cls(GetCapabilitiesCommand.CapabilityId(0), []) @@ -462,215 +564,52 @@ class GetCapabilitiesResponse(Response): return cls(capability_id, capabilities) - def __init__( - self, - capability_id: GetCapabilitiesCommand.CapabilityId, - capabilities: Sequence[Union[SupportsBytes, bytes]], - ) -> None: - super().__init__( - Protocol.PduId.GET_CAPABILITIES, - bytes([capability_id, len(capabilities)]) - + b''.join(bytes(capability) for capability in capabilities), - ) - self.capability_id = capability_id - self.capabilities = list(capabilities) - - def __str__(self) -> str: - return self.to_string( - { - "capability_id": self.capability_id.name, - "capabilities": str(self.capabilities), - } + def __post_init__(self) -> None: + self._payload = bytes([self.capability_id, len(self.capabilities)]) + b''.join( + bytes(capability) for capability in self.capabilities ) # ----------------------------------------------------------------------------- -class GetPlayStatusResponse(Response): - song_length: int - song_position: int - play_status: PlayStatus - - @classmethod - def from_bytes(cls, pdu: bytes) -> GetPlayStatusResponse: - (song_length, song_position) = struct.unpack_from(">II", pdu, 0) - play_status = PlayStatus(pdu[8]) - - return cls(song_length, song_position, play_status) - - def __init__( - self, - song_length: int, - song_position: int, - play_status: PlayStatus, - ) -> None: - super().__init__( - Protocol.PduId.GET_PLAY_STATUS, - struct.pack(">IIB", song_length, song_position, int(play_status)), - ) - self.song_length = song_length - self.song_position = song_position - self.play_status = play_status - - def __str__(self) -> str: - return self.to_string( - { - "song_length": str(self.song_length), - "song_position": str(self.song_position), - "play_status": self.play_status.name, - } - ) - - -# ----------------------------------------------------------------------------- -class GetElementAttributesResponse(Response): - attributes: list[MediaAttribute] - - @classmethod - def from_bytes(cls, pdu: bytes) -> GetElementAttributesResponse: - num_attributes = pdu[0] - offset = 1 - attributes: list[MediaAttribute] = [] - for _ in range(num_attributes): - ( - attribute_id_int, - character_set_id_int, - attribute_value_length, - ) = struct.unpack_from(">IHH", pdu, offset) - attribute_value_bytes = pdu[ - offset + 8 : offset + 8 + attribute_value_length - ] - attribute_id = MediaAttributeId(attribute_id_int) - character_set_id = CharacterSetId(character_set_id_int) - attribute_value = _decode_attribute_value( - attribute_value_bytes, character_set_id - ) - attributes.append( - MediaAttribute(attribute_id, character_set_id, attribute_value) - ) - offset += 8 + attribute_value_length - - return cls(attributes) - - def __init__(self, attributes: Sequence[MediaAttribute]) -> None: - parameter = bytes([len(attributes)]) - for attribute in attributes: - attribute_value_bytes = attribute.attribute_value.encode("utf-8") - parameter += ( - struct.pack( - ">IHH", - int(attribute.attribute_id), - int(CharacterSetId.UTF_8), - len(attribute_value_bytes), - ) - + attribute_value_bytes - ) - super().__init__( - Protocol.PduId.GET_ELEMENT_ATTRIBUTES, - parameter, - ) - self.attributes = list(attributes) - - def __str__(self) -> str: - attribute_strs = [str(attribute) for attribute in self.attributes] - return self.to_string( - { - "attributes": f"[{', '.join(attribute_strs)}]", - } - ) - - -# ----------------------------------------------------------------------------- -class SetAbsoluteVolumeResponse(Response): - volume: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeResponse: - return cls(pdu[0]) - - def __init__(self, volume: int) -> None: - super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume])) - self.volume = volume - - def __str__(self) -> str: - return self.to_string({"volume": str(self.volume)}) - - -# ----------------------------------------------------------------------------- -class RegisterNotificationResponse(Response): - event: Event - - @classmethod - def from_bytes(cls, pdu: bytes) -> RegisterNotificationResponse: - return cls(Event.from_bytes(pdu)) - - def __init__(self, event: Event) -> None: - super().__init__( - Protocol.PduId.REGISTER_NOTIFICATION, - bytes(event), - ) - self.event = event - - def __str__(self) -> str: - return self.to_string( - { - "event": str(self.event), - } - ) - - -# ----------------------------------------------------------------------------- -class EventId(utils.OpenIntEnum): - PLAYBACK_STATUS_CHANGED = 0x01 - TRACK_CHANGED = 0x02 - TRACK_REACHED_END = 0x03 - TRACK_REACHED_START = 0x04 - PLAYBACK_POS_CHANGED = 0x05 - BATT_STATUS_CHANGED = 0x06 - SYSTEM_STATUS_CHANGED = 0x07 - PLAYER_APPLICATION_SETTING_CHANGED = 0x08 - NOW_PLAYING_CONTENT_CHANGED = 0x09 - AVAILABLE_PLAYERS_CHANGED = 0x0A - ADDRESSED_PLAYER_CHANGED = 0x0B - UIDS_CHANGED = 0x0C - VOLUME_CHANGED = 0x0D - - def __bytes__(self) -> bytes: - return bytes([int(self)]) - - -# ----------------------------------------------------------------------------- -class CharacterSetId(utils.OpenIntEnum): - UTF_8 = 0x06 - - -# ----------------------------------------------------------------------------- -class MediaAttributeId(utils.OpenIntEnum): - TITLE = 0x01 - ARTIST_NAME = 0x02 - ALBUM_NAME = 0x03 - TRACK_NUMBER = 0x04 - TOTAL_NUMBER_OF_TRACKS = 0x05 - GENRE = 0x06 - PLAYING_TIME = 0x07 - DEFAULT_COVER_ART = 0x08 - - -# ----------------------------------------------------------------------------- +@Response.register @dataclass -class MediaAttribute: - attribute_id: MediaAttributeId - character_set_id: CharacterSetId - attribute_value: str +class GetPlayStatusResponse(Response): + pdu_id = PduId.GET_PLAY_STATUS + song_length: int = field(metadata=hci.metadata(">4")) + song_position: int = field(metadata=hci.metadata(">4")) + play_status: PlayStatus = field(metadata=PlayStatus.type_metadata(1)) # ----------------------------------------------------------------------------- -class PlayStatus(utils.OpenIntEnum): - STOPPED = 0x00 - PLAYING = 0x01 - PAUSED = 0x02 - FWD_SEEK = 0x03 - REV_SEEK = 0x04 - ERROR = 0xFF +@Response.register +@dataclass +class GetElementAttributesResponse(Response): + pdu_id = PduId.GET_ELEMENT_ATTRIBUTES + attributes: Sequence[MediaAttribute] = field( + metadata=hci.metadata( + MediaAttribute.parse_from_bytes, list_begin=True, list_end=True + ) + ) + + +# ----------------------------------------------------------------------------- +@Response.register +@dataclass +class SetAbsoluteVolumeResponse(Response): + pdu_id = PduId.SET_ABSOLUTE_VOLUME + volume: int = field(metadata=hci.metadata(1)) + + +# ----------------------------------------------------------------------------- +@Response.register +@dataclass +class RegisterNotificationResponse(Response): + pdu_id = PduId.REGISTER_NOTIFICATION + event: Event = field( + metadata=hci.metadata( + lambda data, offset: (len(data), Event.from_bytes(data[offset:])) + ) + ) # ----------------------------------------------------------------------------- @@ -683,256 +622,180 @@ class SongAndPlayStatus: # ----------------------------------------------------------------------------- class ApplicationSetting: - class AttributeId(utils.OpenIntEnum): + class AttributeId(hci.SpecableEnum): EQUALIZER_ON_OFF = 0x01 REPEAT_MODE = 0x02 SHUFFLE_ON_OFF = 0x03 SCAN_ON_OFF = 0x04 - class EqualizerOnOffStatus(utils.OpenIntEnum): + class EqualizerOnOffStatus(hci.SpecableEnum): OFF = 0x01 ON = 0x02 - class RepeatModeStatus(utils.OpenIntEnum): + class RepeatModeStatus(hci.SpecableEnum): OFF = 0x01 SINGLE_TRACK_REPEAT = 0x02 ALL_TRACK_REPEAT = 0x03 GROUP_REPEAT = 0x04 - class ShuffleOnOffStatus(utils.OpenIntEnum): + class ShuffleOnOffStatus(hci.SpecableEnum): OFF = 0x01 ALL_TRACKS_SHUFFLE = 0x02 GROUP_SHUFFLE = 0x03 - class ScanOnOffStatus(utils.OpenIntEnum): + class ScanOnOffStatus(hci.SpecableEnum): OFF = 0x01 ALL_TRACKS_SCAN = 0x02 GROUP_SCAN = 0x03 - class GenericValue(utils.OpenIntEnum): + class GenericValue(hci.SpecableEnum): pass # ----------------------------------------------------------------------------- -@dataclass class Event: event_id: EventId + _pdu: Optional[bytes] = None + + _Event = TypeVar('_Event', bound='Event') + subclasses: ClassVar[dict[int, type[Event]]] = {} + fields: ClassVar[hci.Fields] = () + + @classmethod + def event(cls, subclass: type[_Event]) -> type[_Event]: + cls.subclasses[subclass.event_id] = subclass + subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass) + return subclass @classmethod def from_bytes(cls, pdu: bytes) -> Event: - event_id = EventId(pdu[0]) - subclass = EVENT_SUBCLASSES.get(event_id, GenericEvent) - return subclass.from_bytes(pdu) + if not (subclass := cls.subclasses.get(pdu[0])): + raise core.InvalidPacketError(f"Unimplemented PDU {pdu[0]}") + instance = subclass(**hci.HCI_Object.dict_from_bytes(pdu, 1, subclass.fields)) + instance._pdu = pdu + return instance def __bytes__(self) -> bytes: - return bytes([self.event_id]) + if self._pdu is None: + self._pdu = bytes([self.event_id]) + hci.HCI_Object.dict_to_bytes( + self.__dict__, self.fields + ) + return self._pdu # ----------------------------------------------------------------------------- @dataclass class GenericEvent(Event): - data: bytes + event_id: EventId = field(metadata=EventId.type_metadata(1)) + data: bytes = field(metadata=hci.metadata('*')) - @classmethod - def from_bytes(cls, pdu: bytes) -> GenericEvent: - return cls(event_id=EventId(pdu[0]), data=pdu[1:]) - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + self.data +GenericEvent.fields = hci.HCI_Object.fields_from_dataclass(GenericEvent) # ----------------------------------------------------------------------------- +@Event.event @dataclass class PlaybackStatusChangedEvent(Event): - play_status: PlayStatus - - @classmethod - def from_bytes(cls, pdu: bytes) -> PlaybackStatusChangedEvent: - return cls(play_status=PlayStatus(pdu[1])) - - def __init__(self, play_status: PlayStatus) -> None: - super().__init__(EventId.PLAYBACK_STATUS_CHANGED) - self.play_status = play_status - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + bytes([self.play_status]) + event_id = EventId.PLAYBACK_STATUS_CHANGED + play_status: PlayStatus = field(metadata=PlayStatus.type_metadata(1)) # ----------------------------------------------------------------------------- +@Event.event @dataclass class PlaybackPositionChangedEvent(Event): - playback_position: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> PlaybackPositionChangedEvent: - return cls(playback_position=struct.unpack_from(">I", pdu, 1)[0]) - - def __init__(self, playback_position: int) -> None: - super().__init__(EventId.PLAYBACK_POS_CHANGED) - self.playback_position = playback_position - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + struct.pack(">I", self.playback_position) + event_id = EventId.PLAYBACK_POS_CHANGED + playback_position: int = field(metadata=hci.metadata('>4')) # ----------------------------------------------------------------------------- +@Event.event @dataclass class TrackChangedEvent(Event): - identifier: bytes - - @classmethod - def from_bytes(cls, pdu: bytes) -> TrackChangedEvent: - return cls(identifier=pdu[1:]) - - def __init__(self, identifier: bytes) -> None: - super().__init__(EventId.TRACK_CHANGED) - self.identifier = identifier - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + self.identifier + event_id = EventId.TRACK_CHANGED + identifier: bytes = field(metadata=hci.metadata('*')) # ----------------------------------------------------------------------------- +@Event.event @dataclass class PlayerApplicationSettingChangedEvent(Event): + event_id = EventId.PLAYER_APPLICATION_SETTING_CHANGED + @dataclass - class Setting: - attribute_id: ApplicationSetting.AttributeId - value_id: utils.OpenIntEnum - - player_application_settings: list[Setting] - - @classmethod - def from_bytes(cls, pdu: bytes) -> PlayerApplicationSettingChangedEvent: - def setting(attribute_id_int: int, value_id_int: int): - attribute_id = ApplicationSetting.AttributeId(attribute_id_int) - value_id: utils.OpenIntEnum - if attribute_id == ApplicationSetting.AttributeId.EQUALIZER_ON_OFF: - value_id = ApplicationSetting.EqualizerOnOffStatus(value_id_int) - elif attribute_id == ApplicationSetting.AttributeId.REPEAT_MODE: - value_id = ApplicationSetting.RepeatModeStatus(value_id_int) - elif attribute_id == ApplicationSetting.AttributeId.SHUFFLE_ON_OFF: - value_id = ApplicationSetting.ShuffleOnOffStatus(value_id_int) - elif attribute_id == ApplicationSetting.AttributeId.SCAN_ON_OFF: - value_id = ApplicationSetting.ScanOnOffStatus(value_id_int) - else: - value_id = ApplicationSetting.GenericValue(value_id_int) - - return cls.Setting(attribute_id, value_id) - - settings = [ - setting(pdu[2 + (i * 2)], pdu[2 + (i * 2) + 1]) for i in range(pdu[1]) - ] - return cls(player_application_settings=settings) - - def __init__(self, player_application_settings: Sequence[Setting]) -> None: - super().__init__(EventId.PLAYER_APPLICATION_SETTING_CHANGED) - self.player_application_settings = list(player_application_settings) - - def __bytes__(self) -> bytes: - return ( - bytes([self.event_id]) - + bytes([len(self.player_application_settings)]) - + b''.join( - [ - bytes([setting.attribute_id, setting.value_id]) - for setting in self.player_application_settings - ] - ) + class Setting(hci.HCI_Dataclass_Object): + attribute_id: ApplicationSetting.AttributeId = field( + metadata=ApplicationSetting.AttributeId.type_metadata(1) ) + value_id: Union[ + ApplicationSetting.EqualizerOnOffStatus, + ApplicationSetting.RepeatModeStatus, + ApplicationSetting.ShuffleOnOffStatus, + ApplicationSetting.ScanOnOffStatus, + ApplicationSetting.GenericValue, + ] = field(metadata=hci.metadata(1)) + + def __post_init__(self) -> None: + super().__post_init__() + if self.attribute_id == ApplicationSetting.AttributeId.EQUALIZER_ON_OFF: + self.value_id = ApplicationSetting.EqualizerOnOffStatus(self.value_id) + elif self.attribute_id == ApplicationSetting.AttributeId.REPEAT_MODE: + self.value_id = ApplicationSetting.RepeatModeStatus(self.value_id) + elif self.attribute_id == ApplicationSetting.AttributeId.SHUFFLE_ON_OFF: + self.value_id = ApplicationSetting.ShuffleOnOffStatus(self.value_id) + elif self.attribute_id == ApplicationSetting.AttributeId.SCAN_ON_OFF: + self.value_id = ApplicationSetting.ScanOnOffStatus(self.value_id) + else: + self.value_id = ApplicationSetting.GenericValue(self.value_id) + + player_application_settings: Sequence[Setting] = field( + metadata=hci.metadata(Setting.parse_from_bytes, list_begin=True, list_end=True) + ) # ----------------------------------------------------------------------------- +@Event.event @dataclass class NowPlayingContentChangedEvent(Event): - @classmethod - def from_bytes(cls, pdu: bytes) -> NowPlayingContentChangedEvent: - return cls() - - def __init__(self) -> None: - super().__init__(EventId.NOW_PLAYING_CONTENT_CHANGED) + event_id = EventId.NOW_PLAYING_CONTENT_CHANGED # ----------------------------------------------------------------------------- +@Event.event @dataclass class AvailablePlayersChangedEvent(Event): - @classmethod - def from_bytes(cls, pdu: bytes) -> AvailablePlayersChangedEvent: - return cls() - - def __init__(self) -> None: - super().__init__(EventId.AVAILABLE_PLAYERS_CHANGED) + event_id = EventId.AVAILABLE_PLAYERS_CHANGED # ----------------------------------------------------------------------------- +@Event.event @dataclass class AddressedPlayerChangedEvent(Event): + event_id = EventId.ADDRESSED_PLAYER_CHANGED + @dataclass - class Player: - player_id: int - uid_counter: int + class Player(hci.HCI_Dataclass_Object): + player_id: int = field(metadata=hci.metadata('>2')) + uid_counter: int = field(metadata=hci.metadata('>2')) - @classmethod - def from_bytes(cls, pdu: bytes) -> AddressedPlayerChangedEvent: - player_id, uid_counter = struct.unpack_from(" None: - super().__init__(EventId.ADDRESSED_PLAYER_CHANGED) - self.player = player - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + struct.pack( - ">HH", self.player.player_id, self.player.uid_counter - ) + player: Player = field(metadata=hci.metadata(Player.parse_from_bytes)) # ----------------------------------------------------------------------------- +@Event.event @dataclass class UidsChangedEvent(Event): - uid_counter: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> UidsChangedEvent: - return cls(uid_counter=struct.unpack_from(">H", pdu, 1)[0]) - - def __init__(self, uid_counter: int) -> None: - super().__init__(EventId.UIDS_CHANGED) - self.uid_counter = uid_counter - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + struct.pack(">H", self.uid_counter) + event_id = EventId.UIDS_CHANGED + uid_counter: int = field(metadata=hci.metadata('>2')) # ----------------------------------------------------------------------------- +@Event.event @dataclass class VolumeChangedEvent(Event): - volume: int - - @classmethod - def from_bytes(cls, pdu: bytes) -> VolumeChangedEvent: - return cls(volume=pdu[1]) - - def __init__(self, volume: int) -> None: - super().__init__(EventId.VOLUME_CHANGED) - self.volume = volume - - def __bytes__(self) -> bytes: - return bytes([self.event_id]) + bytes([self.volume]) - - -# ----------------------------------------------------------------------------- -EVENT_SUBCLASSES: dict[EventId, type[Event]] = { - EventId.PLAYBACK_STATUS_CHANGED: PlaybackStatusChangedEvent, - EventId.PLAYBACK_POS_CHANGED: PlaybackPositionChangedEvent, - EventId.TRACK_CHANGED: TrackChangedEvent, - EventId.PLAYER_APPLICATION_SETTING_CHANGED: PlayerApplicationSettingChangedEvent, - EventId.NOW_PLAYING_CONTENT_CHANGED: NowPlayingContentChangedEvent, - EventId.AVAILABLE_PLAYERS_CHANGED: AvailablePlayersChangedEvent, - EventId.ADDRESSED_PLAYER_CHANGED: AddressedPlayerChangedEvent, - EventId.UIDS_CHANGED: UidsChangedEvent, - EventId.VOLUME_CHANGED: VolumeChangedEvent, -} + event_id = EventId.VOLUME_CHANGED + volume: int = field(metadata=hci.metadata(1)) # ----------------------------------------------------------------------------- @@ -947,7 +810,7 @@ class Delegate: class Error(Exception): """The delegate method failed, with a specified status code.""" - def __init__(self, status_code: Protocol.StatusCode) -> None: + def __init__(self, status_code: StatusCode) -> None: self.status_code = status_code supported_events: list[EventId] @@ -989,51 +852,6 @@ class Protocol(utils.EventEmitter): CONTINUE = 0b10 END = 0b11 - class PduId(utils.OpenIntEnum): - GET_CAPABILITIES = 0x10 - LIST_PLAYER_APPLICATION_SETTING_ATTRIBUTES = 0x11 - LIST_PLAYER_APPLICATION_SETTING_VALUES = 0x12 - GET_CURRENT_PLAYER_APPLICATION_SETTING_VALUE = 0x13 - SET_PLAYER_APPLICATION_SETTING_VALUE = 0x14 - GET_PLAYER_APPLICATION_SETTING_ATTRIBUTE_TEXT = 0x15 - GET_PLAYER_APPLICATION_SETTING_VALUE_TEXT = 0x16 - INFORM_DISPLAYABLE_CHARACTER_SET = 0x17 - INFORM_BATTERY_STATUS_OF_CT = 0x18 - GET_ELEMENT_ATTRIBUTES = 0x20 - GET_PLAY_STATUS = 0x30 - REGISTER_NOTIFICATION = 0x31 - REQUEST_CONTINUING_RESPONSE = 0x40 - ABORT_CONTINUING_RESPONSE = 0x41 - SET_ABSOLUTE_VOLUME = 0x50 - SET_ADDRESSED_PLAYER = 0x60 - SET_BROWSED_PLAYER = 0x70 - GET_FOLDER_ITEMS = 0x71 - GET_TOTAL_NUMBER_OF_ITEMS = 0x75 - - class StatusCode(utils.OpenIntEnum): - INVALID_COMMAND = 0x00 - INVALID_PARAMETER = 0x01 - PARAMETER_CONTENT_ERROR = 0x02 - INTERNAL_ERROR = 0x03 - OPERATION_COMPLETED = 0x04 - UID_CHANGED = 0x05 - INVALID_DIRECTION = 0x07 - NOT_A_DIRECTORY = 0x08 - DOES_NOT_EXIST = 0x09 - INVALID_SCOPE = 0x0A - RANGE_OUT_OF_BOUNDS = 0x0B - FOLDER_ITEM_IS_NOT_PLAYABLE = 0x0C - MEDIA_IN_USE = 0x0D - NOW_PLAYING_LIST_FULL = 0x0E - SEARCH_NOT_SUPPORTED = 0x0F - SEARCH_IN_PROGRESS = 0x10 - INVALID_PLAYER_ID = 0x11 - PLAYER_NOT_BROWSABLE = 0x12 - PLAYER_NOT_ADDRESSED = 0x13 - NO_VALID_SEARCH_RESULTS = 0x14 - NO_AVAILABLE_PLAYERS = 0x15 - ADDRESSED_PLAYER_CHANGED = 0x16 - class InvalidPidError(Exception): """A response frame with ipid==1 was received.""" @@ -1147,7 +965,9 @@ class Protocol(utils.EventEmitter): A 'connection' event will be emitted when a connection is made, and a 'start' event will be emitted when the protocol is ready to be used on that connection. """ - device.register_l2cap_server(avctp.AVCTP_PSM, self._on_avctp_connection) + device.create_l2cap_server( + l2cap.ClassicChannelSpec(avctp.AVCTP_PSM), self._on_avctp_connection + ) async def connect(self, connection: Connection) -> None: """ @@ -1208,7 +1028,7 @@ class Protocol(utils.EventEmitter): self.send_rejected_avrcp_response( transaction_label, command.pdu_id, - Protocol.StatusCode.INTERNAL_ERROR, + StatusCode.INTERNAL_ERROR, ) utils.AsyncRunner.spawn(call()) @@ -1243,7 +1063,7 @@ class Protocol(utils.EventEmitter): GetElementAttributesCommand(element_identifier, attribute_ids), ) response = self._check_response(response_context, GetElementAttributesResponse) - return response.attributes + return list(response.attributes) async def monitor_events( self, event_id: EventId, playback_interval: int = 0 @@ -1326,7 +1146,7 @@ class Protocol(utils.EventEmitter): if not isinstance(event, PlayerApplicationSettingChangedEvent): logger.warning("unexpected event class") continue - yield event.player_application_settings + yield list(event.player_application_settings) async def monitor_now_playing_content(self) -> AsyncIterator[None]: """Monitor Now Playing changes from the connected peer.""" @@ -1596,29 +1416,24 @@ class Protocol(utils.EventEmitter): avc.CommandFrame.CommandType.NOTIFY, ): # TODO: catch exceptions from delegates - if pdu_id == self.PduId.GET_CAPABILITIES: - self._on_get_capabilities_command( - transaction_label, GetCapabilitiesCommand.from_bytes(pdu) - ) - elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME: - self._on_set_absolute_volume_command( - transaction_label, SetAbsoluteVolumeCommand.from_bytes(pdu) - ) - elif pdu_id == self.PduId.REGISTER_NOTIFICATION: - self._on_register_notification_command( - transaction_label, RegisterNotificationCommand.from_bytes(pdu) - ) + command = Command.from_bytes(pdu_id, pdu) + if isinstance(command, GetCapabilitiesCommand): + self._on_get_capabilities_command(transaction_label, command) + elif isinstance(command, SetAbsoluteVolumeCommand): + self._on_set_absolute_volume_command(transaction_label, command) + elif isinstance(command, RegisterNotificationCommand): + self._on_register_notification_command(transaction_label, command) else: # Not supported. # TODO: check that this is the right way to respond in this case. logger.debug("unsupported PDU ID") self.send_rejected_avrcp_response( - transaction_label, pdu_id, self.StatusCode.INVALID_PARAMETER + transaction_label, pdu_id, StatusCode.INVALID_PARAMETER ) else: logger.debug("unsupported command type") self.send_rejected_avrcp_response( - transaction_label, pdu_id, self.StatusCode.INVALID_COMMAND + transaction_label, pdu_id, StatusCode.INVALID_COMMAND ) self.receive_command_state = None @@ -1643,25 +1458,25 @@ class Protocol(utils.EventEmitter): # more appropriate. response: Optional[Response] = None if response_code == avc.ResponseFrame.ResponseCode.REJECTED: - response = RejectedResponse.from_bytes(pdu_id, pdu) + response = RejectedResponse.from_bytes(pdu_id=pdu_id, pdu=pdu) elif response_code == avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED: - response = NotImplementedResponse.from_bytes(pdu_id, pdu) + response = NotImplementedResponse.from_bytes(pdu_id=pdu_id, pdu=pdu) elif response_code in ( avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE, avc.ResponseFrame.ResponseCode.INTERIM, avc.ResponseFrame.ResponseCode.CHANGED, avc.ResponseFrame.ResponseCode.ACCEPTED, ): - if pdu_id == self.PduId.GET_CAPABILITIES: - response = GetCapabilitiesResponse.from_bytes(pdu) - elif pdu_id == self.PduId.GET_PLAY_STATUS: - response = GetPlayStatusResponse.from_bytes(pdu) - elif pdu_id == self.PduId.GET_ELEMENT_ATTRIBUTES: - response = GetElementAttributesResponse.from_bytes(pdu) - elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME: - response = SetAbsoluteVolumeResponse.from_bytes(pdu) - elif pdu_id == self.PduId.REGISTER_NOTIFICATION: - response = RegisterNotificationResponse.from_bytes(pdu) + if pdu_id == PduId.GET_CAPABILITIES: + response = GetCapabilitiesResponse.from_bytes(pdu=pdu) + elif pdu_id == PduId.GET_PLAY_STATUS: + response = GetPlayStatusResponse.from_bytes(pdu=pdu) + elif pdu_id == PduId.GET_ELEMENT_ATTRIBUTES: + response = GetElementAttributesResponse.from_bytes(pdu=pdu) + elif pdu_id == PduId.SET_ABSOLUTE_VOLUME: + response = SetAbsoluteVolumeResponse.from_bytes(pdu=pdu) + elif pdu_id == PduId.REGISTER_NOTIFICATION: + response = RegisterNotificationResponse.from_bytes(pdu=pdu) else: logger.debug("unexpected PDU ID") pending_command.response.set_exception( @@ -1757,10 +1572,8 @@ class Protocol(utils.EventEmitter): # TODO: fragmentation # Send the command. logger.debug(f">>> AVRCP command PDU: {command}") - pdu = ( - struct.pack(">BBH", command.pdu_id, 0, len(command.parameter)) - + command.parameter - ) + payload = bytes(command) + pdu = struct.pack(">BBH", command.pdu_id, 0, len(payload)) + payload command_frame = avc.VendorDependentCommandFrame( command_type, avc.Frame.SubunitType.PANEL, @@ -1804,10 +1617,8 @@ class Protocol(utils.EventEmitter): ) -> None: # TODO: fragmentation logger.debug(f">>> AVRCP response PDU: {response}") - pdu = ( - struct.pack(">BBH", response.pdu_id, 0, len(response.parameter)) - + response.parameter - ) + payload = bytes(response) + pdu = struct.pack(">BBH", response.pdu_id, 0, len(payload)) + payload response_frame = avc.VendorDependentResponseFrame( response_code, avc.Frame.SubunitType.PANEL, @@ -1830,7 +1641,7 @@ class Protocol(utils.EventEmitter): self.send_response(transaction_label, response) def send_rejected_avrcp_response( - self, transaction_label: int, pdu_id: Protocol.PduId, status_code: StatusCode + self, transaction_label: int, pdu_id: PduId, status_code: StatusCode ) -> None: self.send_avrcp_response( transaction_label, @@ -1839,7 +1650,7 @@ class Protocol(utils.EventEmitter): ) def send_not_implemented_avrcp_response( - self, transaction_label: int, pdu_id: Protocol.PduId + self, transaction_label: int, pdu_id: PduId ) -> None: self.send_avrcp_response( transaction_label, @@ -1895,7 +1706,7 @@ class Protocol(utils.EventEmitter): if command.event_id not in supported_events: logger.debug("event not supported") self.send_not_implemented_avrcp_response( - transaction_label, self.PduId.REGISTER_NOTIFICATION + transaction_label, PduId.REGISTER_NOTIFICATION ) return diff --git a/bumble/core.py b/bumble/core.py index d7c6cc90..4eb6a975 100644 --- a/bumble/core.py +++ b/bumble/core.py @@ -17,9 +17,21 @@ # ----------------------------------------------------------------------------- from __future__ import annotations +import dataclasses import enum import struct -from typing import Literal, Optional, Union, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Iterable, + Literal, + Optional, + Type, + Union, + cast, + overload, +) from typing_extensions import Self @@ -331,6 +343,9 @@ class UUID: result += f' ({self.name})' return result + def __repr__(self) -> str: + return self.to_hex_str() + # ----------------------------------------------------------------------------- # Common UUID constants @@ -447,26 +462,25 @@ BT_HDP_SINK_SERVICE = UUID.from_16_bits(0x1402, # ----------------------------------------------------------------------------- -# DeviceClass +# ClassOfDevice +# See Bluetooth - Assigned Numbers - 2.8 Class of Device # ----------------------------------------------------------------------------- -class DeviceClass: +@dataclasses.dataclass +class ClassOfDevice: # fmt: off - # pylint: disable=line-too-long + class MajorServiceClasses(utils.CompatibleIntFlag): + LIMITED_DISCOVERABLE_MODE = (1 << 0) + LE_AUDIO = (1 << 1) + POSITIONING = (1 << 3) + NETWORKING = (1 << 4) + RENDERING = (1 << 5) + CAPTURING = (1 << 6) + OBJECT_TRANSFER = (1 << 7) + AUDIO = (1 << 8) + TELEPHONY = (1 << 9) + INFORMATION = (1 << 10) - # Major Service Classes (flags combined with OR) - LIMITED_DISCOVERABLE_MODE_SERVICE_CLASS = (1 << 0) - LE_AUDIO_SERVICE_CLASS = (1 << 1) - RESERVED = (1 << 2) - POSITIONING_SERVICE_CLASS = (1 << 3) - NETWORKING_SERVICE_CLASS = (1 << 4) - RENDERING_SERVICE_CLASS = (1 << 5) - CAPTURING_SERVICE_CLASS = (1 << 6) - OBJECT_TRANSFER_SERVICE_CLASS = (1 << 7) - AUDIO_SERVICE_CLASS = (1 << 8) - TELEPHONY_SERVICE_CLASS = (1 << 9) - INFORMATION_SERVICE_CLASS = (1 << 10) - - SERVICE_CLASS_LABELS = [ + MAJOR_SERVICE_CLASS_LABELS: ClassVar[list[str]] = [ 'Limited Discoverable Mode', 'LE audio', '(reserved)', @@ -477,219 +491,439 @@ class DeviceClass: 'Object Transfer', 'Audio', 'Telephony', - 'Information' + 'Information', ] + class MajorDeviceClass(utils.OpenIntEnum): + MISCELLANEOUS = 0x00 + COMPUTER = 0x01 + PHONE = 0x02 + LAN_NETWORK_ACCESS_POINT = 0x03 + AUDIO_VIDEO = 0x04 + PERIPHERAL = 0x05 + IMAGING = 0x06 + WEARABLE = 0x07 + TOY = 0x08 + HEALTH = 0x09 + UNCATEGORIZED = 0x1F + + MAJOR_DEVICE_CLASS_LABELS: ClassVar[dict[MajorDeviceClass, str]] = { + MajorDeviceClass.MISCELLANEOUS: 'Miscellaneous', + MajorDeviceClass.COMPUTER: 'Computer', + MajorDeviceClass.PHONE: 'Phone', + MajorDeviceClass.LAN_NETWORK_ACCESS_POINT: 'LAN/Network Access Point', + MajorDeviceClass.AUDIO_VIDEO: 'Audio/Video', + MajorDeviceClass.PERIPHERAL: 'Peripheral', + MajorDeviceClass.IMAGING: 'Imaging', + MajorDeviceClass.WEARABLE: 'Wearable', + MajorDeviceClass.TOY: 'Toy', + MajorDeviceClass.HEALTH: 'Health', + MajorDeviceClass.UNCATEGORIZED: 'Uncategorized', + } + + class ComputerMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + DESKTOP_WORKSTATION = 0x01 + SERVER_CLASS_COMPUTER = 0x02 + LAPTOP_COMPUTER = 0x03 + HANDHELD_PC_PDA = 0x04 + PALM_SIZE_PC_PDA = 0x05 + WEARABLE_COMPUTER = 0x06 + TABLET = 0x07 + + COMPUTER_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[ComputerMinorDeviceClass, str]] = { + ComputerMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + ComputerMinorDeviceClass.DESKTOP_WORKSTATION: 'Desktop workstation', + ComputerMinorDeviceClass.SERVER_CLASS_COMPUTER: 'Server-class computer', + ComputerMinorDeviceClass.LAPTOP_COMPUTER: 'Laptop', + ComputerMinorDeviceClass.HANDHELD_PC_PDA: 'Handheld PC/PDA', + ComputerMinorDeviceClass.PALM_SIZE_PC_PDA: 'Palm-size PC/PDA', + ComputerMinorDeviceClass.WEARABLE_COMPUTER: 'Wearable computer', + ComputerMinorDeviceClass.TABLET: 'Tablet', + } + + class PhoneMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + CELLULAR = 0x01 + CORDLESS = 0x02 + SMARTPHONE = 0x03 + WIRED_MODEM_OR_VOICE_GATEWAY = 0x04 + COMMON_ISDN = 0x05 + + PHONE_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[PhoneMinorDeviceClass, str]] = { + PhoneMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + PhoneMinorDeviceClass.CELLULAR: 'Cellular', + PhoneMinorDeviceClass.CORDLESS: 'Cordless', + PhoneMinorDeviceClass.SMARTPHONE: 'Smartphone', + PhoneMinorDeviceClass.WIRED_MODEM_OR_VOICE_GATEWAY: 'Wired modem or voice gateway', + PhoneMinorDeviceClass.COMMON_ISDN: 'Common ISDN access', + } + + class LanNetworkMinorDeviceClass(utils.OpenIntEnum): + FULLY_AVAILABLE = 0x00 + _1_TO_17_PERCENT_UTILIZED = 0x01 + _17_TO_33_PERCENT_UTILIZED = 0x02 + _33_TO_50_PERCENT_UTILIZED = 0x03 + _50_TO_67_PERCENT_UTILIZED = 0x04 + _67_TO_83_PERCENT_UTILIZED = 0x05 + _83_TO_99_PERCENT_UTILIZED = 0x06 + _NO_SERVICE_AVAILABLE = 0x07 + + LAN_NETWORK_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[LanNetworkMinorDeviceClass, str]] = { + LanNetworkMinorDeviceClass.FULLY_AVAILABLE: 'Fully availbable', + LanNetworkMinorDeviceClass._1_TO_17_PERCENT_UTILIZED: '1% to 17% utilized', + LanNetworkMinorDeviceClass._17_TO_33_PERCENT_UTILIZED: '17% to 33% utilized', + LanNetworkMinorDeviceClass._33_TO_50_PERCENT_UTILIZED: '33% to 50% utilized', + LanNetworkMinorDeviceClass._50_TO_67_PERCENT_UTILIZED: '50% to 67% utilized', + LanNetworkMinorDeviceClass._67_TO_83_PERCENT_UTILIZED: '67% to 83% utilized', + LanNetworkMinorDeviceClass._83_TO_99_PERCENT_UTILIZED: '83% to 99% utilized', + LanNetworkMinorDeviceClass._NO_SERVICE_AVAILABLE: 'No service available', + } + + class AudioVideoMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + WEARABLE_HEADSET_DEVICE = 0x01 + HANDS_FREE_DEVICE = 0x02 + # (RESERVED) = 0x03 + MICROPHONE = 0x04 + LOUDSPEAKER = 0x05 + HEADPHONES = 0x06 + PORTABLE_AUDIO = 0x07 + CAR_AUDIO = 0x08 + SET_TOP_BOX = 0x09 + HIFI_AUDIO_DEVICE = 0x0A + VCR = 0x0B + VIDEO_CAMERA = 0x0C + CAMCORDER = 0x0D + VIDEO_MONITOR = 0x0E + VIDEO_DISPLAY_AND_LOUDSPEAKER = 0x0F + VIDEO_CONFERENCING = 0x10 + # (RESERVED) = 0x11 + GAMING_OR_TOY = 0x12 + + AUDIO_VIDEO_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[AudioVideoMinorDeviceClass, str]] = { + AudioVideoMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + AudioVideoMinorDeviceClass.WEARABLE_HEADSET_DEVICE: 'Wearable Headset Device', + AudioVideoMinorDeviceClass.HANDS_FREE_DEVICE: 'Hands-free Device', + AudioVideoMinorDeviceClass.MICROPHONE: 'Microphone', + AudioVideoMinorDeviceClass.LOUDSPEAKER: 'Loudspeaker', + AudioVideoMinorDeviceClass.HEADPHONES: 'Headphones', + AudioVideoMinorDeviceClass.PORTABLE_AUDIO: 'Portable Audio', + AudioVideoMinorDeviceClass.CAR_AUDIO: 'Car audio', + AudioVideoMinorDeviceClass.SET_TOP_BOX: 'Set-top box', + AudioVideoMinorDeviceClass.HIFI_AUDIO_DEVICE: 'HiFi Audio Device', + AudioVideoMinorDeviceClass.VCR: 'VCR', + AudioVideoMinorDeviceClass.VIDEO_CAMERA: 'Video Camera', + AudioVideoMinorDeviceClass.CAMCORDER: 'Camcorder', + AudioVideoMinorDeviceClass.VIDEO_MONITOR: 'Video Monitor', + AudioVideoMinorDeviceClass.VIDEO_DISPLAY_AND_LOUDSPEAKER: 'Video Display and Loudspeaker', + AudioVideoMinorDeviceClass.VIDEO_CONFERENCING: 'Video Conferencing', + AudioVideoMinorDeviceClass.GAMING_OR_TOY: 'Gaming/Toy', + } + + class PeripheralMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + KEYBOARD = 0x10 + POINTING_DEVICE = 0x20 + COMBO_KEYBOARD_POINTING_DEVICE = 0x30 + JOYSTICK = 0x01 + GAMEPAD = 0x02 + REMOTE_CONTROL = 0x03 + SENSING_DEVICE = 0x04 + DIGITIZER_TABLET = 0x05 + CARD_READER = 0x06 + DIGITAL_PEN = 0x07 + HANDHELD_SCANNER = 0x08 + HANDHELD_GESTURAL_INPUT_DEVICE = 0x09 + + PERIPHERAL_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[PeripheralMinorDeviceClass, str]] = { + PeripheralMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + PeripheralMinorDeviceClass.KEYBOARD: 'Keyboard', + PeripheralMinorDeviceClass.POINTING_DEVICE: 'Pointing device', + PeripheralMinorDeviceClass.COMBO_KEYBOARD_POINTING_DEVICE: 'Combo keyboard/pointing device', + PeripheralMinorDeviceClass.JOYSTICK: 'Joystick', + PeripheralMinorDeviceClass.GAMEPAD: 'Gamepad', + PeripheralMinorDeviceClass.REMOTE_CONTROL: 'Remote control', + PeripheralMinorDeviceClass.SENSING_DEVICE: 'Sensing device', + PeripheralMinorDeviceClass.DIGITIZER_TABLET: 'Digitizer tablet', + PeripheralMinorDeviceClass.CARD_READER: 'Card Reader', + PeripheralMinorDeviceClass.DIGITAL_PEN: 'Digital Pen', + PeripheralMinorDeviceClass.HANDHELD_SCANNER: 'Handheld scanner', + PeripheralMinorDeviceClass.HANDHELD_GESTURAL_INPUT_DEVICE: 'Handheld gestural input device', + } + + class WearableMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + WRISTWATCH = 0x01 + PAGER = 0x02 + JACKET = 0x03 + HELMET = 0x04 + GLASSES = 0x05 + + WEARABLE_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[WearableMinorDeviceClass, str]] = { + WearableMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + WearableMinorDeviceClass.WRISTWATCH: 'Wristwatch', + WearableMinorDeviceClass.PAGER: 'Pager', + WearableMinorDeviceClass.JACKET: 'Jacket', + WearableMinorDeviceClass.HELMET: 'Helmet', + WearableMinorDeviceClass.GLASSES: 'Glasses', + } + + class ToyMinorDeviceClass(utils.OpenIntEnum): + UNCATEGORIZED = 0x00 + ROBOT = 0x01 + VEHICLE = 0x02 + DOLL_ACTION_FIGURE = 0x03 + CONTROLLER = 0x04 + GAME = 0x05 + + TOY_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[ToyMinorDeviceClass, str]] = { + ToyMinorDeviceClass.UNCATEGORIZED: 'Uncategorized', + ToyMinorDeviceClass.ROBOT: 'Robot', + ToyMinorDeviceClass.VEHICLE: 'Vehicle', + ToyMinorDeviceClass.DOLL_ACTION_FIGURE: 'Doll/Action figure', + ToyMinorDeviceClass.CONTROLLER: 'Controller', + ToyMinorDeviceClass.GAME: 'Game', + } + + class HealthMinorDeviceClass(utils.OpenIntEnum): + UNDEFINED = 0x00 + BLOOD_PRESSURE_MONITOR = 0x01 + THERMOMETER = 0x02 + WEIGHING_SCALE = 0x03 + GLUCOSE_METER = 0x04 + PULSE_OXIMETER = 0x05 + HEART_PULSE_RATE_MONITOR = 0x06 + HEALTH_DATA_DISPLAY = 0x07 + STEP_COUNTER = 0x08 + BODY_COMPOSITION_ANALYZER = 0x09 + PEAK_FLOW_MONITOR = 0x0A + MEDICATION_MONITOR = 0x0B + KNEE_PROSTHESIS = 0x0C + ANKLE_PROSTHESIS = 0x0D + GENERIC_HEALTH_MANAGER = 0x0E + PERSONAL_MOBILITY_DEVICE = 0x0F + + HEALTH_MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[HealthMinorDeviceClass, str]] = { + HealthMinorDeviceClass.UNDEFINED: 'Undefined', + HealthMinorDeviceClass.BLOOD_PRESSURE_MONITOR: 'Blood Pressure Monitor', + HealthMinorDeviceClass.THERMOMETER: 'Thermometer', + HealthMinorDeviceClass.WEIGHING_SCALE: 'Weighing Scale', + HealthMinorDeviceClass.GLUCOSE_METER: 'Glucose Meter', + HealthMinorDeviceClass.PULSE_OXIMETER: 'Pulse Oximeter', + HealthMinorDeviceClass.HEART_PULSE_RATE_MONITOR: 'Heart/Pulse Rate Monitor', + HealthMinorDeviceClass.HEALTH_DATA_DISPLAY: 'Health Data Display', + HealthMinorDeviceClass.STEP_COUNTER: 'Step Counter', + HealthMinorDeviceClass.BODY_COMPOSITION_ANALYZER: 'Body Composition Analyzer', + HealthMinorDeviceClass.PEAK_FLOW_MONITOR: 'Peak Flow Monitor', + HealthMinorDeviceClass.MEDICATION_MONITOR: 'Medication Monitor', + HealthMinorDeviceClass.KNEE_PROSTHESIS: 'Knee Prosthesis', + HealthMinorDeviceClass.ANKLE_PROSTHESIS: 'Ankle Prosthesis', + HealthMinorDeviceClass.GENERIC_HEALTH_MANAGER: 'Generic Health Manager', + HealthMinorDeviceClass.PERSONAL_MOBILITY_DEVICE: 'Personal Mobility Device', + } + + MINOR_DEVICE_CLASS_LABELS: ClassVar[dict[MajorDeviceClass, dict[Any, str]]] = { + MajorDeviceClass.COMPUTER: COMPUTER_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.PHONE: PHONE_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.LAN_NETWORK_ACCESS_POINT: LAN_NETWORK_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.AUDIO_VIDEO: AUDIO_VIDEO_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.PERIPHERAL: PERIPHERAL_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.WEARABLE: WEARABLE_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.TOY: TOY_MINOR_DEVICE_CLASS_LABELS, + MajorDeviceClass.HEALTH: HEALTH_MINOR_DEVICE_CLASS_LABELS, + } + + _MINOR_DEVICE_CLASSES: ClassVar[dict[MajorDeviceClass, Type]] = { + MajorDeviceClass.COMPUTER: ComputerMinorDeviceClass, + MajorDeviceClass.PHONE: PhoneMinorDeviceClass, + MajorDeviceClass.LAN_NETWORK_ACCESS_POINT: LanNetworkMinorDeviceClass, + MajorDeviceClass.AUDIO_VIDEO: AudioVideoMinorDeviceClass, + MajorDeviceClass.PERIPHERAL: PeripheralMinorDeviceClass, + MajorDeviceClass.WEARABLE: WearableMinorDeviceClass, + MajorDeviceClass.TOY: ToyMinorDeviceClass, + MajorDeviceClass.HEALTH: HealthMinorDeviceClass, + } + + # fmt: on + + major_service_classes: MajorServiceClasses + major_device_class: MajorDeviceClass + minor_device_class: Union[ + ComputerMinorDeviceClass, + PhoneMinorDeviceClass, + LanNetworkMinorDeviceClass, + AudioVideoMinorDeviceClass, + PeripheralMinorDeviceClass, + WearableMinorDeviceClass, + ToyMinorDeviceClass, + HealthMinorDeviceClass, + int, + ] + + @classmethod + def from_int(cls, class_of_device: int) -> Self: + major_service_classes = cls.MajorServiceClasses(class_of_device >> 13 & 0x7FF) + major_device_class = cls.MajorDeviceClass(class_of_device >> 8 & 0x1F) + minor_device_class_int = class_of_device >> 2 & 0x3F + if minor_device_class_object := cls._MINOR_DEVICE_CLASSES.get( + major_device_class + ): + minor_device_class = minor_device_class_object(minor_device_class_int) + else: + minor_device_class = minor_device_class_int + return cls(major_service_classes, major_device_class, minor_device_class) + + def __int__(self) -> int: + return ( + self.major_service_classes << 13 + | self.major_device_class << 8 + | self.minor_device_class << 2 + ) + + def __str__(self) -> str: + minor_device_class_name = ( + self.minor_device_class.name + if hasattr(self.minor_device_class, 'name') + else hex(self.minor_device_class) + ) + return ( + f"ClassOfDevice({self.major_service_classes.composite_name}," + f"{self.major_device_class.name}/{minor_device_class_name})" + ) + + def major_service_classes_labels(self) -> str: + return "|".join( + bit_flags_to_strings( + self.major_service_classes, self.MAJOR_SERVICE_CLASS_LABELS + ) + ) + + def major_device_class_label(self) -> str: + return name_or_number( + cast(dict[int, str], self.MAJOR_DEVICE_CLASS_LABELS), + self.major_device_class, + ) + + def minor_device_class_label(self) -> str: + class_names = self.MINOR_DEVICE_CLASS_LABELS.get(self.major_device_class) + if class_names is None: + return f'#{self.minor_device_class:02X}' + return name_or_number(class_names, self.minor_device_class) + + +# ----------------------------------------------------------------------------- +# DeviceClass +# ----------------------------------------------------------------------------- +class DeviceClass: + """Legacy only. Use ClassOfDevice instead""" + + # fmt: off + # pylint: disable=line-too-long + + # Major Service Classes (flags combined with OR) + LIMITED_DISCOVERABLE_MODE_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.LIMITED_DISCOVERABLE_MODE + LE_AUDIO_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.LE_AUDIO + POSITIONING_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.POSITIONING + NETWORKING_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.NETWORKING + RENDERING_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.RENDERING + CAPTURING_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.CAPTURING + OBJECT_TRANSFER_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.OBJECT_TRANSFER + AUDIO_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.AUDIO + TELEPHONY_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.TELEPHONY + INFORMATION_SERVICE_CLASS = ClassOfDevice.MajorServiceClasses.INFORMATION + # Major Device Classes - MISCELLANEOUS_MAJOR_DEVICE_CLASS = 0x00 - COMPUTER_MAJOR_DEVICE_CLASS = 0x01 - PHONE_MAJOR_DEVICE_CLASS = 0x02 - LAN_NETWORK_ACCESS_POINT_MAJOR_DEVICE_CLASS = 0x03 - AUDIO_VIDEO_MAJOR_DEVICE_CLASS = 0x04 - PERIPHERAL_MAJOR_DEVICE_CLASS = 0x05 - IMAGING_MAJOR_DEVICE_CLASS = 0x06 - WEARABLE_MAJOR_DEVICE_CLASS = 0x07 - TOY_MAJOR_DEVICE_CLASS = 0x08 - HEALTH_MAJOR_DEVICE_CLASS = 0x09 - UNCATEGORIZED_MAJOR_DEVICE_CLASS = 0x1F + MISCELLANEOUS_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.MISCELLANEOUS + COMPUTER_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.COMPUTER + PHONE_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.PHONE + LAN_NETWORK_ACCESS_POINT_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.LAN_NETWORK_ACCESS_POINT + AUDIO_VIDEO_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.AUDIO_VIDEO + PERIPHERAL_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.PERIPHERAL + IMAGING_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.IMAGING + WEARABLE_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.WEARABLE + TOY_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.TOY + HEALTH_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.HEALTH + UNCATEGORIZED_MAJOR_DEVICE_CLASS = ClassOfDevice.MajorDeviceClass.UNCATEGORIZED - MAJOR_DEVICE_CLASS_NAMES = { - MISCELLANEOUS_MAJOR_DEVICE_CLASS: 'Miscellaneous', - COMPUTER_MAJOR_DEVICE_CLASS: 'Computer', - PHONE_MAJOR_DEVICE_CLASS: 'Phone', - LAN_NETWORK_ACCESS_POINT_MAJOR_DEVICE_CLASS: 'LAN/Network Access Point', - AUDIO_VIDEO_MAJOR_DEVICE_CLASS: 'Audio/Video', - PERIPHERAL_MAJOR_DEVICE_CLASS: 'Peripheral', - IMAGING_MAJOR_DEVICE_CLASS: 'Imaging', - WEARABLE_MAJOR_DEVICE_CLASS: 'Wearable', - TOY_MAJOR_DEVICE_CLASS: 'Toy', - HEALTH_MAJOR_DEVICE_CLASS: 'Health', - UNCATEGORIZED_MAJOR_DEVICE_CLASS: 'Uncategorized' - } + COMPUTER_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.UNCATEGORIZED + COMPUTER_DESKTOP_WORKSTATION_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.DESKTOP_WORKSTATION + COMPUTER_SERVER_CLASS_COMPUTER_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.SERVER_CLASS_COMPUTER + COMPUTER_LAPTOP_COMPUTER_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.LAPTOP_COMPUTER + COMPUTER_HANDHELD_PC_PDA_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.HANDHELD_PC_PDA + COMPUTER_PALM_SIZE_PC_PDA_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.PALM_SIZE_PC_PDA + COMPUTER_WEARABLE_COMPUTER_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.WEARABLE_COMPUTER + COMPUTER_TABLET_MINOR_DEVICE_CLASS = ClassOfDevice.ComputerMinorDeviceClass.TABLET - COMPUTER_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - COMPUTER_DESKTOP_WORKSTATION_MINOR_DEVICE_CLASS = 0x01 - COMPUTER_SERVER_CLASS_COMPUTER_MINOR_DEVICE_CLASS = 0x02 - COMPUTER_LAPTOP_COMPUTER_MINOR_DEVICE_CLASS = 0x03 - COMPUTER_HANDHELD_PC_PDA_MINOR_DEVICE_CLASS = 0x04 - COMPUTER_PALM_SIZE_PC_PDA_MINOR_DEVICE_CLASS = 0x05 - COMPUTER_WEARABLE_COMPUTER_MINOR_DEVICE_CLASS = 0x06 - COMPUTER_TABLET_MINOR_DEVICE_CLASS = 0x07 + PHONE_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.UNCATEGORIZED + PHONE_CELLULAR_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.CELLULAR + PHONE_CORDLESS_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.CORDLESS + PHONE_SMARTPHONE_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.SMARTPHONE + PHONE_WIRED_MODEM_OR_VOICE_GATEWAY_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.WIRED_MODEM_OR_VOICE_GATEWAY + PHONE_COMMON_ISDN_MINOR_DEVICE_CLASS = ClassOfDevice.PhoneMinorDeviceClass.COMMON_ISDN - COMPUTER_MINOR_DEVICE_CLASS_NAMES = { - COMPUTER_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - COMPUTER_DESKTOP_WORKSTATION_MINOR_DEVICE_CLASS: 'Desktop workstation', - COMPUTER_SERVER_CLASS_COMPUTER_MINOR_DEVICE_CLASS: 'Server-class computer', - COMPUTER_LAPTOP_COMPUTER_MINOR_DEVICE_CLASS: 'Laptop', - COMPUTER_HANDHELD_PC_PDA_MINOR_DEVICE_CLASS: 'Handheld PC/PDA', - COMPUTER_PALM_SIZE_PC_PDA_MINOR_DEVICE_CLASS: 'Palm-size PC/PDA', - COMPUTER_WEARABLE_COMPUTER_MINOR_DEVICE_CLASS: 'Wearable computer', - COMPUTER_TABLET_MINOR_DEVICE_CLASS: 'Tablet' - } + AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.UNCATEGORIZED + AUDIO_VIDEO_WEARABLE_HEADSET_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.WEARABLE_HEADSET_DEVICE + AUDIO_VIDEO_HANDS_FREE_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.HANDS_FREE_DEVICE + AUDIO_VIDEO_MICROPHONE_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.MICROPHONE + AUDIO_VIDEO_LOUDSPEAKER_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.LOUDSPEAKER + AUDIO_VIDEO_HEADPHONES_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.HEADPHONES + AUDIO_VIDEO_PORTABLE_AUDIO_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.PORTABLE_AUDIO + AUDIO_VIDEO_CAR_AUDIO_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.CAR_AUDIO + AUDIO_VIDEO_SET_TOP_BOX_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.SET_TOP_BOX + AUDIO_VIDEO_HIFI_AUDIO_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.HIFI_AUDIO_DEVICE + AUDIO_VIDEO_VCR_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.VCR + AUDIO_VIDEO_VIDEO_CAMERA_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.VIDEO_CAMERA + AUDIO_VIDEO_CAMCORDER_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.CAMCORDER + AUDIO_VIDEO_VIDEO_MONITOR_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.VIDEO_MONITOR + AUDIO_VIDEO_VIDEO_DISPLAY_AND_LOUDSPEAKER_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.VIDEO_DISPLAY_AND_LOUDSPEAKER + AUDIO_VIDEO_VIDEO_CONFERENCING_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.VIDEO_CONFERENCING + AUDIO_VIDEO_GAMING_OR_TOY_MINOR_DEVICE_CLASS = ClassOfDevice.AudioVideoMinorDeviceClass.GAMING_OR_TOY - PHONE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - PHONE_CELLULAR_MINOR_DEVICE_CLASS = 0x01 - PHONE_CORDLESS_MINOR_DEVICE_CLASS = 0x02 - PHONE_SMARTPHONE_MINOR_DEVICE_CLASS = 0x03 - PHONE_WIRED_MODEM_OR_VOICE_GATEWAY_MINOR_DEVICE_CLASS = 0x04 - PHONE_COMMON_ISDN_MINOR_DEVICE_CLASS = 0x05 + PERIPHERAL_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.UNCATEGORIZED + PERIPHERAL_KEYBOARD_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.KEYBOARD + PERIPHERAL_POINTING_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.POINTING_DEVICE + PERIPHERAL_COMBO_KEYBOARD_POINTING_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.COMBO_KEYBOARD_POINTING_DEVICE + PERIPHERAL_JOYSTICK_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.JOYSTICK + PERIPHERAL_GAMEPAD_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.GAMEPAD + PERIPHERAL_REMOTE_CONTROL_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.REMOTE_CONTROL + PERIPHERAL_SENSING_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.SENSING_DEVICE + PERIPHERAL_DIGITIZER_TABLET_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.DIGITIZER_TABLET + PERIPHERAL_CARD_READER_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.CARD_READER + PERIPHERAL_DIGITAL_PEN_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.DIGITAL_PEN + PERIPHERAL_HANDHELD_SCANNER_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.HANDHELD_SCANNER + PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.PeripheralMinorDeviceClass.HANDHELD_GESTURAL_INPUT_DEVICE - PHONE_MINOR_DEVICE_CLASS_NAMES = { - PHONE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - PHONE_CELLULAR_MINOR_DEVICE_CLASS: 'Cellular', - PHONE_CORDLESS_MINOR_DEVICE_CLASS: 'Cordless', - PHONE_SMARTPHONE_MINOR_DEVICE_CLASS: 'Smartphone', - PHONE_WIRED_MODEM_OR_VOICE_GATEWAY_MINOR_DEVICE_CLASS: 'Wired modem or voice gateway', - PHONE_COMMON_ISDN_MINOR_DEVICE_CLASS: 'Common ISDN access' - } + WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.UNCATEGORIZED + WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.WRISTWATCH + WEARABLE_PAGER_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.PAGER + WEARABLE_JACKET_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.JACKET + WEARABLE_HELMET_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.HELMET + WEARABLE_GLASSES_MINOR_DEVICE_CLASS = ClassOfDevice.WearableMinorDeviceClass.GLASSES - AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - AUDIO_VIDEO_WEARABLE_HEADSET_DEVICE_MINOR_DEVICE_CLASS = 0x01 - AUDIO_VIDEO_HANDS_FREE_DEVICE_MINOR_DEVICE_CLASS = 0x02 - # (RESERVED) = 0x03 - AUDIO_VIDEO_MICROPHONE_MINOR_DEVICE_CLASS = 0x04 - AUDIO_VIDEO_LOUDSPEAKER_MINOR_DEVICE_CLASS = 0x05 - AUDIO_VIDEO_HEADPHONES_MINOR_DEVICE_CLASS = 0x06 - AUDIO_VIDEO_PORTABLE_AUDIO_MINOR_DEVICE_CLASS = 0x07 - AUDIO_VIDEO_CAR_AUDIO_MINOR_DEVICE_CLASS = 0x08 - AUDIO_VIDEO_SET_TOP_BOX_MINOR_DEVICE_CLASS = 0x09 - AUDIO_VIDEO_HIFI_AUDIO_DEVICE_MINOR_DEVICE_CLASS = 0x0A - AUDIO_VIDEO_VCR_MINOR_DEVICE_CLASS = 0x0B - AUDIO_VIDEO_VIDEO_CAMERA_MINOR_DEVICE_CLASS = 0x0C - AUDIO_VIDEO_CAMCORDER_MINOR_DEVICE_CLASS = 0x0D - AUDIO_VIDEO_VIDEO_MONITOR_MINOR_DEVICE_CLASS = 0x0E - AUDIO_VIDEO_VIDEO_DISPLAY_AND_LOUDSPEAKER_MINOR_DEVICE_CLASS = 0x0F - AUDIO_VIDEO_VIDEO_CONFERENCING_MINOR_DEVICE_CLASS = 0x10 - # (RESERVED) = 0x11 - AUDIO_VIDEO_GAMING_OR_TOY_MINOR_DEVICE_CLASS = 0x12 + TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.UNCATEGORIZED + TOY_ROBOT_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.ROBOT + TOY_VEHICLE_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.VEHICLE + TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.DOLL_ACTION_FIGURE + TOY_CONTROLLER_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.CONTROLLER + TOY_GAME_MINOR_DEVICE_CLASS = ClassOfDevice.ToyMinorDeviceClass.GAME - AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES = { - AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - AUDIO_VIDEO_WEARABLE_HEADSET_DEVICE_MINOR_DEVICE_CLASS: 'Wearable Headset Device', - AUDIO_VIDEO_HANDS_FREE_DEVICE_MINOR_DEVICE_CLASS: 'Hands-free Device', - AUDIO_VIDEO_MICROPHONE_MINOR_DEVICE_CLASS: 'Microphone', - AUDIO_VIDEO_LOUDSPEAKER_MINOR_DEVICE_CLASS: 'Loudspeaker', - AUDIO_VIDEO_HEADPHONES_MINOR_DEVICE_CLASS: 'Headphones', - AUDIO_VIDEO_PORTABLE_AUDIO_MINOR_DEVICE_CLASS: 'Portable Audio', - AUDIO_VIDEO_CAR_AUDIO_MINOR_DEVICE_CLASS: 'Car audio', - AUDIO_VIDEO_SET_TOP_BOX_MINOR_DEVICE_CLASS: 'Set-top box', - AUDIO_VIDEO_HIFI_AUDIO_DEVICE_MINOR_DEVICE_CLASS: 'HiFi Audio Device', - AUDIO_VIDEO_VCR_MINOR_DEVICE_CLASS: 'VCR', - AUDIO_VIDEO_VIDEO_CAMERA_MINOR_DEVICE_CLASS: 'Video Camera', - AUDIO_VIDEO_CAMCORDER_MINOR_DEVICE_CLASS: 'Camcorder', - AUDIO_VIDEO_VIDEO_MONITOR_MINOR_DEVICE_CLASS: 'Video Monitor', - AUDIO_VIDEO_VIDEO_DISPLAY_AND_LOUDSPEAKER_MINOR_DEVICE_CLASS: 'Video Display and Loudspeaker', - AUDIO_VIDEO_VIDEO_CONFERENCING_MINOR_DEVICE_CLASS: 'Video Conferencing', - AUDIO_VIDEO_GAMING_OR_TOY_MINOR_DEVICE_CLASS: 'Gaming/Toy' - } - - PERIPHERAL_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - PERIPHERAL_KEYBOARD_MINOR_DEVICE_CLASS = 0x10 - PERIPHERAL_POINTING_DEVICE_MINOR_DEVICE_CLASS = 0x20 - PERIPHERAL_COMBO_KEYBOARD_POINTING_DEVICE_MINOR_DEVICE_CLASS = 0x30 - PERIPHERAL_JOYSTICK_MINOR_DEVICE_CLASS = 0x01 - PERIPHERAL_GAMEPAD_MINOR_DEVICE_CLASS = 0x02 - PERIPHERAL_REMOTE_CONTROL_MINOR_DEVICE_CLASS = 0x03 - PERIPHERAL_SENSING_DEVICE_MINOR_DEVICE_CLASS = 0x04 - PERIPHERAL_DIGITIZER_TABLET_MINOR_DEVICE_CLASS = 0x05 - PERIPHERAL_CARD_READER_MINOR_DEVICE_CLASS = 0x06 - PERIPHERAL_DIGITAL_PEN_MINOR_DEVICE_CLASS = 0x07 - PERIPHERAL_HANDHELD_SCANNER_MINOR_DEVICE_CLASS = 0x08 - PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS = 0x09 - - PERIPHERAL_MINOR_DEVICE_CLASS_NAMES = { - PERIPHERAL_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - PERIPHERAL_KEYBOARD_MINOR_DEVICE_CLASS: 'Keyboard', - PERIPHERAL_POINTING_DEVICE_MINOR_DEVICE_CLASS: 'Pointing device', - PERIPHERAL_COMBO_KEYBOARD_POINTING_DEVICE_MINOR_DEVICE_CLASS: 'Combo keyboard/pointing device', - PERIPHERAL_JOYSTICK_MINOR_DEVICE_CLASS: 'Joystick', - PERIPHERAL_GAMEPAD_MINOR_DEVICE_CLASS: 'Gamepad', - PERIPHERAL_REMOTE_CONTROL_MINOR_DEVICE_CLASS: 'Remote control', - PERIPHERAL_SENSING_DEVICE_MINOR_DEVICE_CLASS: 'Sensing device', - PERIPHERAL_DIGITIZER_TABLET_MINOR_DEVICE_CLASS: 'Digitizer tablet', - PERIPHERAL_CARD_READER_MINOR_DEVICE_CLASS: 'Card Reader', - PERIPHERAL_DIGITAL_PEN_MINOR_DEVICE_CLASS: 'Digital Pen', - PERIPHERAL_HANDHELD_SCANNER_MINOR_DEVICE_CLASS: 'Handheld scanner', - PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device' - } - - WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01 - WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02 - WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03 - WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04 - WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05 - - WEARABLE_MINOR_DEVICE_CLASS_NAMES = { - WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch', - WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager', - WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket', - WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet', - WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses', - } - - TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00 - TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01 - TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02 - TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03 - TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04 - TOY_GAME_MINOR_DEVICE_CLASS = 0x05 - - TOY_MINOR_DEVICE_CLASS_NAMES = { - TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized', - TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot', - TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle', - TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure', - TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller', - TOY_GAME_MINOR_DEVICE_CLASS: 'Game', - } - - HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00 - HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01 - HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02 - HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03 - HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04 - HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05 - HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06 - HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07 - HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08 - HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09 - HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A - HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B - HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C - HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D - HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E - HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F - - HEALTH_MINOR_DEVICE_CLASS_NAMES = { - HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined', - HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor', - HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer', - HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale', - HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter', - HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter', - HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor', - HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display', - HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter', - HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer', - HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor', - HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor', - HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis', - HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis', - HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager', - HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device', - } - - MINOR_DEVICE_CLASS_NAMES = { - COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES, - PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES, - AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES, - PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES, - WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES, - TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES, - HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES, - } + HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.UNDEFINED + HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.BLOOD_PRESSURE_MONITOR + HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.THERMOMETER + HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.WEIGHING_SCALE + HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.GLUCOSE_METER + HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.PULSE_OXIMETER + HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.HEART_PULSE_RATE_MONITOR + HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.HEALTH_DATA_DISPLAY + HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.STEP_COUNTER + HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.BODY_COMPOSITION_ANALYZER + HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.PEAK_FLOW_MONITOR + HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.MEDICATION_MONITOR + HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.KNEE_PROSTHESIS + HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.ANKLE_PROSTHESIS + HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.GENERIC_HEALTH_MANAGER + HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = ClassOfDevice.HealthMinorDeviceClass.PERSONAL_MOBILITY_DEVICE # fmt: on # pylint: enable=line-too-long @@ -711,16 +945,16 @@ class DeviceClass: @staticmethod def service_class_labels(service_class_flags): return bit_flags_to_strings( - service_class_flags, DeviceClass.SERVICE_CLASS_LABELS + service_class_flags, ClassOfDevice.MAJOR_SERVICE_CLASS_LABELS ) @staticmethod def major_device_class_name(device_class): - return name_or_number(DeviceClass.MAJOR_DEVICE_CLASS_NAMES, device_class) + return name_or_number(ClassOfDevice.MAJOR_DEVICE_CLASS_LABELS, device_class) @staticmethod def minor_device_class_name(major_device_class, minor_device_class): - class_names = DeviceClass.MINOR_DEVICE_CLASS_NAMES.get(major_device_class) + class_names = ClassOfDevice.MINOR_DEVICE_CLASS_LABELS.get(major_device_class) if class_names is None: return f'#{minor_device_class:02X}' return name_or_number(class_names, minor_device_class) @@ -1255,6 +1489,10 @@ class Appearance: category = cls.Category(appearance >> 6) return cls(category, appearance & 0x3F) + @classmethod + def from_bytes(cls, data: bytes): + return cls.from_int(int.from_bytes(data, byteorder="little")) + def __init__(self, category: Category, subcategory: int) -> None: self.category = category if subcategory_class := self.SUBCATEGORY_CLASSES.get(category): @@ -1265,6 +1503,9 @@ class Appearance: def __int__(self) -> int: return self.category << 6 | self.subcategory + def __bytes__(self) -> bytes: + return int(self).to_bytes(2, byteorder="little") + def __repr__(self) -> str: return ( 'Appearance(' @@ -1276,6 +1517,61 @@ class Appearance: def __str__(self) -> str: return f'{self.category.name}/{self.subcategory.name}' + def __eq__(self, value: Any) -> bool: + return ( + isinstance(value, Appearance) + and self.category == value.category + and self.subcategory == value.subcategory + ) + + +# ----------------------------------------------------------------------------- +# Classes representing "Data Types" defined in +# "Supplement to the Bluetooth Core Specification", Part A +# ----------------------------------------------------------------------------- +# TODO: use ABC, figure out multiple base classes with metaclasses +class DataType: + # Human-reable label/name for the type + label = "" + + # Advertising Data type ID for this data type. + ad_type: AdvertisingData.Type = 0 # type: ignore + + def value_string(self) -> str: + """Human-reable string representation of the value.""" + raise NotImplementedError() + + def to_string(self, use_label: bool = False) -> str: + if use_label: + return f"[{self.label}]: {self.value_string()}" + + return f"{self.__class__.__name__}({self.value_string()})" + + @classmethod + def from_advertising_data(cls, advertising_data: AdvertisingData) -> Optional[Self]: + if (data := advertising_data.get(cls.ad_type, raw=True)) is None: + return None + + return cls.from_bytes(data) + + @classmethod + def all_from_advertising_data(cls, advertising_data: AdvertisingData) -> list[Self]: + return [ + cls.from_bytes(data) + for data in advertising_data.get_all(cls.ad_type, raw=True) + ] + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + """Create an instance from a serialized form.""" + raise NotImplementedError() + + def __bytes__(self) -> bytes: + raise NotImplementedError() + + def __str__(self) -> str: + return self.to_string() + # ----------------------------------------------------------------------------- # Advertising Data @@ -1351,7 +1647,7 @@ class AdvertisingData: THREE_D_INFORMATION_DATA = 0x3D MANUFACTURER_SPECIFIC_DATA = 0xFF - class Flags(enum.IntFlag): + class Flags(utils.CompatibleIntFlag): LE_LIMITED_DISCOVERABLE_MODE = 1 << 0 LE_GENERAL_DISCOVERABLE_MODE = 1 << 1 BR_EDR_NOT_SUPPORTED = 1 << 2 @@ -1419,15 +1715,25 @@ class AdvertisingData: BR_EDR_CONTROLLER_FLAG = Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE BR_EDR_HOST_FLAG = 0x10 # Deprecated - ad_structures: list[tuple[int, bytes]] + ad_structures: list[tuple[AdvertisingData.Type, bytes]] # fmt: on # pylint: enable=line-too-long - def __init__(self, ad_structures: Optional[list[tuple[int, bytes]]] = None) -> None: + def __init__( + self, + ad_structures: Optional[Iterable[Union[tuple[int, bytes], DataType]]] = None, + ) -> None: if ad_structures is None: ad_structures = [] - self.ad_structures = ad_structures[:] + self.ad_structures = [ + ( + (element.ad_type, bytes(element)) + if isinstance(element, DataType) + else (AdvertisingData.Type(element[0]), element[1]) + ) + for element in ad_structures + ] @classmethod def from_bytes(cls, data: bytes) -> AdvertisingData: @@ -1444,11 +1750,10 @@ class AdvertisingData: 'LE Limited Discoverable Mode', 'LE General Discoverable Mode', 'BR/EDR Not Supported', - 'Simultaneous LE and BR/EDR (Controller)', - 'Simultaneous LE and BR/EDR (Host)', + 'Simultaneous LE and BR/EDR', ] ) - return ','.join(bit_flags_to_strings(flags, flag_names)) + return ', '.join(bit_flags_to_strings(flags, flag_names)) @staticmethod def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> list[UUID]: @@ -1604,7 +1909,7 @@ class AdvertisingData: if length > 0: ad_type = data[offset] ad_data = data[offset + 1 : offset + length] - self.ad_structures.append((ad_type, ad_data)) + self.ad_structures.append((AdvertisingData.Type(ad_type), ad_data)) offset += length @overload @@ -1623,6 +1928,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> list[list[UUID]]: ... + @overload def get_all( self, @@ -1633,6 +1939,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> list[tuple[UUID, bytes]]: ... + @overload def get_all( self, @@ -1644,6 +1951,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> list[str]: ... + @overload def get_all( self, @@ -1655,26 +1963,31 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> list[int]: ... + @overload def get_all( self, type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,], raw: Literal[False] = False, ) -> list[tuple[int, int]]: ... + @overload def get_all( self, type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,], raw: Literal[False] = False, ) -> list[tuple[int, bytes]]: ... + @overload def get_all( self, type_id: Literal[AdvertisingData.Type.APPEARANCE,], raw: Literal[False] = False, ) -> list[Appearance]: ... + @overload def get_all(self, type_id: int, raw: Literal[True]) -> list[bytes]: ... + @overload def get_all( self, type_id: int, raw: bool = False @@ -1682,7 +1995,7 @@ class AdvertisingData: def get_all(self, type_id: int, raw: bool = False) -> list[AdvertisingDataObject]: # type: ignore[misc] ''' - Get Advertising Data Structure(s) with a given type + Get all advertising data elements as simple AdvertisingDataObject objects. Returns a (possibly empty) list of matches. ''' @@ -1708,6 +2021,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> Optional[list[UUID]]: ... + @overload def get( self, @@ -1718,6 +2032,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> Optional[tuple[UUID, bytes]]: ... + @overload def get( self, @@ -1729,6 +2044,7 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> Optional[Optional[str]]: ... + @overload def get( self, @@ -1740,26 +2056,31 @@ class AdvertisingData: ], raw: Literal[False] = False, ) -> Optional[int]: ... + @overload def get( self, type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,], raw: Literal[False] = False, ) -> Optional[tuple[int, int]]: ... + @overload def get( self, type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,], raw: Literal[False] = False, ) -> Optional[tuple[int, bytes]]: ... + @overload def get( self, type_id: Literal[AdvertisingData.Type.APPEARANCE,], raw: Literal[False] = False, ) -> Optional[Appearance]: ... + @overload def get(self, type_id: int, raw: Literal[True]) -> Optional[bytes]: ... + @overload def get( self, type_id: int, raw: bool = False @@ -1767,7 +2088,7 @@ class AdvertisingData: def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]: ''' - Get Advertising Data Structure(s) with a given type + Get advertising data as a simple AdvertisingDataObject object. Returns the first entry, or None if no structure matches. ''' @@ -1822,7 +2143,22 @@ class ConnectionPHY: # LE Role # ----------------------------------------------------------------------------- class LeRole(enum.IntEnum): - PERIPHERAL_ONLY = 0x00 - CENTRAL_ONLY = 0x01 + # fmt: off + PERIPHERAL_ONLY = 0x00 + CENTRAL_ONLY = 0x01 BOTH_PERIPHERAL_PREFERRED = 0x02 - BOTH_CENTRAL_PREFERRED = 0x03 + BOTH_CENTRAL_PREFERRED = 0x03 + + +# ----------------------------------------------------------------------------- +# Security Manager OOB Flag +# ----------------------------------------------------------------------------- +class SecurityManagerOutOfBandFlag(utils.CompatibleIntFlag): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.7 SECURITY MANAGER OUT OF BAND (OOB) + """ + + OOB_FLAGS_FIELD = 1 << 0 + LE_SUPPORTED = 1 << 1 + ADDRESS_TYPE = 1 << 3 diff --git a/bumble/data_types.py b/bumble/data_types.py new file mode 100644 index 00000000..59c43794 --- /dev/null +++ b/bumble/data_types.py @@ -0,0 +1,1025 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Classes representing "Data Types" defined in +"Supplement to the Bluetooth Core Specification", Part A and +"Assigned Numbers", 2.3 Common Data Types. +""" + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations + +import dataclasses +import math +import struct +from typing import Any, ClassVar, Sequence + +from typing_extensions import Self + +from bumble import company_ids, core, hci + + +# ----------------------------------------------------------------------------- +class GenericAdvertisingData(core.DataType): + """Data Type for which there is no specific subclass""" + + label = "Generic Advertising Data" + ad_data: bytes + + def __init__(self, ad_data: bytes, ad_type: core.AdvertisingData.Type) -> None: + self.ad_data = ad_data + self.ad_type = ad_type + + def value_string(self) -> str: + return f"type={self.ad_type.name}, data={self.ad_data.hex().upper()}" + + @classmethod + def from_bytes( + cls, + ad_data: bytes, + ad_type: core.AdvertisingData.Type = core.AdvertisingData.Type(0), + ) -> GenericAdvertisingData: + return cls(ad_data, ad_type) + + def __bytes__(self) -> bytes: + return self.ad_data + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, GenericAdvertisingData) + and self.ad_type == other.ad_type + and self.ad_data == other.ad_data + ) + + +@dataclasses.dataclass +class ListOfServiceUUIDs(core.DataType): + """Base class for complete or incomplete lists of UUIDs.""" + + _uuid_size: ClassVar[int] = 0 + uuids: Sequence[core.UUID] + + @classmethod + def from_bytes(cls, data: bytes) -> ListOfServiceUUIDs: + return cls( + [ + core.UUID.from_bytes(data[x : x + cls._uuid_size]) + for x in range(0, len(data), cls._uuid_size) + ] + ) + + def __post_init__(self) -> None: + for uuid in self.uuids: + if len(uuid.uuid_bytes) != self._uuid_size: + raise TypeError("incompatible UUID type") + + def __bytes__(self) -> bytes: + return b"".join(bytes(uuid) for uuid in self.uuids) + + def value_string(self) -> str: + return ", ".join(list(map(str, self.uuids))) + + +class IncompleteListOf16BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 2 + label = "Incomplete List Of 16-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS + + +class CompleteListOf16BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 2 + label = "Complete List Of 16-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS + + +class IncompleteListOf32BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 4 + label = "Incomplete List Of 32-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS + + +class CompleteListOf32BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 4 + label = "Complete List Of 32-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS + + +class IncompleteListOf128BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 16 + label = "Incomplete List Of 128-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS + + +class CompleteListOf128BitServiceUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.1 SERVICE OR SERVICE CLASS UUID + """ + + _uuid_size = 16 + label = "Complete List Of 128-bit Service or Service Class UUIDs" + ad_type = core.AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS + + +class StringDataType(str, core.DataType): + @classmethod + def from_bytes(cls, data: bytes) -> Self: + return cls(data.decode("utf-8")) + + def __bytes__(self) -> bytes: + return self.encode("utf-8") + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def value_string(self) -> str: + return repr(self) + + +class CompleteLocalName(StringDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.2 LOCAL NAME + """ + + label = "Complete Local Name" + ad_type = core.AdvertisingData.COMPLETE_LOCAL_NAME + + +class ShortenedLocalName(StringDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.2 LOCAL NAME + """ + + label = "Shortened Local Name" + ad_type = core.AdvertisingData.SHORTENED_LOCAL_NAME + + +class Flags(int, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.3 FLAGS + """ + + label = "Flags" + ad_type = core.AdvertisingData.FLAGS + + def __init__(self, flags: core.AdvertisingData.Flags) -> None: + pass + + @classmethod + def from_bytes(cls, data: bytes) -> Flags: # type: ignore[override] + return cls(core.AdvertisingData.Flags(int.from_bytes(data, byteorder="little"))) + + def __bytes__(self) -> bytes: + bytes_length = 1 if self == 0 else math.ceil(self.bit_length() / 8) + return self.to_bytes(length=bytes_length, byteorder="little") + + def value_string(self) -> str: + return core.AdvertisingData.Flags(self).composite_name + + +@dataclasses.dataclass +class ManufacturerSpecificData(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.4 MANUFACTURER SPECIFIC DATA + """ + + label = "Manufacturer Specific Data" + ad_type = core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA + + company_identifier: int + data: bytes + + @classmethod + def from_bytes(cls, data: bytes) -> ManufacturerSpecificData: + company_identifier = int.from_bytes(data[:2], "little") + return cls(company_identifier, data[2:]) + + def __bytes__(self) -> bytes: + return self.company_identifier.to_bytes(2, "little") + self.data + + def value_string(self) -> str: + if company := company_ids.COMPANY_IDENTIFIERS.get(self.company_identifier): + company_str = repr(company) + else: + company_str = f'0x{self.company_identifier:04X}' + return f"company={company_str}, data={self.data.hex().upper()}" + + +class FixedSizeIntDataType(int, core.DataType): + _fixed_size: int = 0 + _signed: bool = False + + @classmethod + def from_bytes(cls, data: bytes) -> Self: # type: ignore[override] + if len(data) != cls._fixed_size: + raise ValueError(f"data must be {cls._fixed_size} byte") + return cls(int.from_bytes(data, byteorder="little", signed=cls._signed)) + + def __bytes__(self) -> bytes: + return self.to_bytes( + length=self._fixed_size, byteorder="little", signed=self._signed + ) + + def value_string(self) -> str: + return str(int(self)) + + +class TxPowerLevel(FixedSizeIntDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.5 TX POWER LEVEL + """ + + _fixed_size = 1 + _signed = True + label = "TX Power Level" + ad_type = core.AdvertisingData.Type.TX_POWER_LEVEL + + +class FixedSizeBytesDataType(bytes, core.DataType): + _fixed_size: int = 0 + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + if len(data) != cls._fixed_size: + raise ValueError(f"data must be {cls._fixed_size} bytes") + return cls(data) + + def value_string(self) -> str: + return self.hex().upper() + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def __bytes__(self) -> bytes: # pylint: disable=E0308 + # Python < 3.11 compatibility (before 3.11, the byte class does not have + # a __bytes__ method). + # Concatenate with an empty string to perform a direct conversion without + # calling bytes() explicity, which may cause an infinite recursion. + return b"" + self + + +class ClassOfDevice(core.ClassOfDevice, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + label = "Class of Device" + ad_type = core.AdvertisingData.Type.CLASS_OF_DEVICE + + @classmethod + def from_bytes(cls, data: bytes) -> ClassOfDevice: + return cls.from_int(int.from_bytes(data, byteorder="little")) + + def __bytes__(self) -> bytes: + return int(self).to_bytes(3, byteorder="little") + + def __eq__(self, value: Any) -> bool: + return core.ClassOfDevice.__eq__(self, value) + + def value_string(self) -> str: + return ( + f"{self.major_service_classes_labels()}," + f"{self.major_device_class_label()}/" + f"{self.minor_device_class_label()}" + ) + + def __str__(self) -> str: + return core.DataType.__str__(self) + + +class SecureSimplePairingHashC192(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "Secure Simple Pairing Hash C-192" + ad_type = core.AdvertisingData.Type.SIMPLE_PAIRING_HASH_C_192 + + +class SecureSimplePairingRandomizerR192(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "Secure Simple Pairing Randomizer R-192" + ad_type = core.AdvertisingData.Type.SIMPLE_PAIRING_RANDOMIZER_R_192 + + +class SecureSimplePairingHashC256(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "Secure Simple Pairing Hash C-256" + ad_type = core.AdvertisingData.Type.SIMPLE_PAIRING_HASH_C_256 + + +class SecureSimplePairingRandomizerR256(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "Secure Simple Pairing Randomizer R-256" + ad_type = core.AdvertisingData.Type.SIMPLE_PAIRING_RANDOMIZER_R_256 + + +class LeSecureConnectionsConfirmationValue(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "LE Secure Connections Confirmation Value" + ad_type = core.AdvertisingData.Type.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE + + +class LeSecureConnectionsRandomValue(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.6 SECURE SIMPLE PAIRING OUT OF BAND (OOB) + """ + + _fixed_size = 16 + label = "LE Secure Connections Random Value" + ad_type = core.AdvertisingData.Type.LE_SECURE_CONNECTIONS_RANDOM_VALUE + + +class SecurityManagerOutOfBandFlag(int, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.7 SECURITY MANAGER OUT OF BAND (OOB) + """ + + label = "Security Manager Out of Band Flag" + ad_type = core.AdvertisingData.Type.SECURITY_MANAGER_OUT_OF_BAND_FLAGS + + def __init__(self, flag: core.SecurityManagerOutOfBandFlag) -> None: + pass + + @classmethod + # type: ignore[override] + def from_bytes(cls, data: bytes) -> SecurityManagerOutOfBandFlag: + if len(data) != 1: + raise ValueError("data must be 1 byte") + return SecurityManagerOutOfBandFlag(core.SecurityManagerOutOfBandFlag(data[0])) + + def __bytes__(self) -> bytes: + return bytes([self]) + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def value_string(self) -> str: + return core.SecurityManagerOutOfBandFlag(self).composite_name + + +class SecurityManagerTKValue(FixedSizeBytesDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.8 SECURITY MANAGER TK VALUE + """ + + _fixed_size = 16 + label = "Security Manager TK Value" + ad_type = core.AdvertisingData.Type.SECURITY_MANAGER_TK_VALUE + + +@dataclasses.dataclass +class PeripheralConnectionIntervalRange(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.9 PERIPHERAL CONNECTION INTERVAL RANGE + """ + + label = "Peripheral Connection Interval Range" + ad_type = core.AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE + + connection_interval_min: int + connection_interval_max: int + + @classmethod + def from_bytes(cls, data: bytes) -> PeripheralConnectionIntervalRange: + return cls(*struct.unpack(" bytes: + return struct.pack( + " str: + return ( + f"connection_interval_min={self.connection_interval_min}, " + f"connection_interval_max={self.connection_interval_max}" + ) + + +class ListOf16BitServiceSolicitationUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.10 SERVICE SOLICITATION + """ + + _uuid_size = 2 + label = "List of 16 bit Service Solicitation UUIDs" + ad_type = core.AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS + + +class ListOf32BitServiceSolicitationUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.10 SERVICE SOLICITATION + """ + + _uuid_size = 4 + label = "List of 32 bit Service Solicitation UUIDs" + ad_type = core.AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS + + +class ListOf128BitServiceSolicitationUUIDs(ListOfServiceUUIDs): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.10 SERVICE SOLICITATION + """ + + _uuid_size = 16 + label = "List of 128 bit Service Solicitation UUIDs" + ad_type = core.AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS + + +@dataclasses.dataclass +class ServiceData(core.DataType): + """Base class for service data lists of UUIDs.""" + + _uuid_size: ClassVar[int] = 0 + + service_uuid: core.UUID + data: bytes + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + service_uuid = core.UUID.from_bytes(data[: cls._uuid_size]) + return cls(service_uuid, data[cls._uuid_size :]) + + def __bytes__(self) -> bytes: + return self.service_uuid.to_bytes() + self.data + + def value_string(self) -> str: + return f"service={self.service_uuid}, data={self.data.hex().upper()}" + + +class ServiceData16BitUUID(ServiceData): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.11 SERVICE DATA + """ + + _uuid_size = 2 + label = "Service Data - 16 bit UUID" + ad_type = core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID + + +class ServiceData32BitUUID(ServiceData): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.11 SERVICE DATA + """ + + _uuid_size = 4 + label = "Service Data - 32 bit UUID" + ad_type = core.AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID + + +class ServiceData128BitUUID(ServiceData): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.11 SERVICE DATA + """ + + _uuid_size = 16 + label = "Service Data - 128 bit UUID" + ad_type = core.AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID + + +class Appearance(core.Appearance, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.12 APPEARANCE + """ + + label = "Appearance" + ad_type = core.AdvertisingData.Type.APPEARANCE + + @classmethod + def from_bytes(cls, data: bytes): + return cls.from_int(int.from_bytes(data, byteorder="little")) + + def __bytes__(self) -> bytes: + return int(self).to_bytes(2, byteorder="little") + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def value_string(self) -> str: + return core.Appearance.__str__(self) + + +class PublicTargetAddress(hci.Address, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.13 PUBLIC TARGET ADDRESS + """ + + label = "Public Target Address" + ad_type = core.AdvertisingData.Type.PUBLIC_TARGET_ADDRESS + + def __init__(self, address: hci.Address) -> None: + self.address_bytes = address.address_bytes + self.address_type = hci.Address.PUBLIC_DEVICE_ADDRESS + + @classmethod + def from_bytes(cls, data: bytes) -> PublicTargetAddress: + return cls(hci.Address(data)) + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def to_string(self, use_label: bool = False) -> str: + return core.DataType.to_string(self, use_label) + + def value_string(self) -> str: + return hci.Address.to_string(self, with_type_qualifier=False) + + +class RandomTargetAddress(hci.Address, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.14 RANDOM TARGET ADDRESS + """ + + label = "Random Target Address" + ad_type = core.AdvertisingData.Type.RANDOM_TARGET_ADDRESS + + def __init__(self, address: hci.Address) -> None: + self.address_bytes = address.address_bytes + self.address_type = hci.Address.RANDOM_DEVICE_ADDRESS + + @classmethod + def from_bytes(cls, data: bytes) -> RandomTargetAddress: + return cls(hci.Address(data)) + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def to_string(self, use_label: bool = False) -> str: + return core.DataType.to_string(self, use_label) + + def value_string(self) -> str: + return hci.Address.to_string(self, with_type_qualifier=False) + + +class AdvertisingInterval(FixedSizeIntDataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.15 ADVERTISING INTERVAL + """ + + _fixed_size = 2 + label = "Advertising Interval" + ad_type = core.AdvertisingData.Type.ADVERTISING_INTERVAL + + +class AdvertisingIntervalLong(int, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.15 ADVERTISING INTERVAL + """ + + label = "Advertising Interval - long" + ad_type = core.AdvertisingData.Type.ADVERTISING_INTERVAL_LONG + + @classmethod + def from_bytes(cls, data: bytes) -> Self: # type: ignore[override] + return cls(int.from_bytes(data, byteorder="little")) + + def __bytes__(self) -> bytes: + return self.to_bytes(length=4 if self >= 0x1000000 else 3, byteorder="little") + + def value_string(self) -> str: + return str(int(self)) + + +class LeBluetoothDeviceAddress(hci.Address, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.16 LE BLUETOOTH DEVICE ADDRESS + """ + + label = "LE Bluetooth Device Address" + ad_type = core.AdvertisingData.Type.LE_BLUETOOTH_DEVICE_ADDRESS + + def __init__(self, address: hci.Address) -> None: + self.address_bytes = address.address_bytes + self.address_type = address.address_type + + @classmethod + def from_bytes(cls, data: bytes) -> LeBluetoothDeviceAddress: + return cls(hci.Address(data[1:], hci.AddressType(data[0]))) + + def __bytes__(self) -> bytes: + return bytes([self.address_type]) + self.address_bytes + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def to_string(self, use_label: bool = False) -> str: + return core.DataType.to_string(self, use_label) + + def value_string(self) -> str: + return ( + f"{hci.Address.to_string(self, with_type_qualifier=False)}" + f"/{'PUBLIC' if self.is_public else 'RANDOM'}" + ) + + +class LeRole(int, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.17 LE ROLE + """ + + label = "LE Role" + ad_type = core.AdvertisingData.Type.LE_ROLE + + def __init__(self, role: core.LeRole) -> None: + pass + + @classmethod + def from_bytes(cls, data: bytes) -> Self: # type: ignore[override] + return cls(core.LeRole(data[0])) + + def __bytes__(self) -> bytes: + return bytes([self]) + + def value_string(self) -> str: + return core.LeRole(self).name + + +class Uri(str, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.18 UNIFORM RESOURCE IDENTIFIER (URI) + """ + + label = "URI" + ad_type = core.AdvertisingData.Type.URI + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + return cls(data.decode("utf-8")) + + def __bytes__(self): + return self.encode("utf-8") + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def value_string(self) -> str: + return repr(self) + + +class LeSupportedFeatures(int, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.19 LE SUPPORTED FEATURES + """ + + label = "LE Supported Features" + ad_type = core.AdvertisingData.Type.LE_SUPPORTED_FEATURES + + @classmethod + def from_bytes(cls, data: bytes) -> LeSupportedFeatures: # type: ignore[override] + return cls(int.from_bytes(data, byteorder="little")) + + def __bytes__(self) -> bytes: + bytes_length = 1 if self == 0 else math.ceil(self.bit_length() / 8) + return self.to_bytes(length=bytes_length, byteorder="little") + + def value_string(self) -> str: + return hci.LeFeatureMask(self).composite_name + + +@dataclasses.dataclass +class ChannelMapUpdateIndication(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.20 CHANNEL MAP UPDATE INDICATION + """ + + label = "Channel Map Update Indication" + ad_type = core.AdvertisingData.Type.CHANNEL_MAP_UPDATE_INDICATION + + chm: int + instant: int + + @classmethod + def from_bytes(cls, data: bytes) -> ChannelMapUpdateIndication: + return cls( + int.from_bytes(data[:5], byteorder="little"), + int.from_bytes(data[5:7], byteorder="little"), + ) + + def __bytes__(self) -> bytes: + return self.chm.to_bytes(5, byteorder="little") + self.instant.to_bytes( + 2, byteorder="little" + ) + + def value_string(self) -> str: + return f"chm={self.chm:010X}, instant={self.instant}" + + +class BigInfo(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.21 BIGINFO + """ + + # TODO + + +class BroadcastCode(str, core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.22 BROADCAST_CODE + """ + + label = "Broadcast Code" + ad_type = core.AdvertisingData.Type.BROADCAST_CODE + + def __init__(self, value: str) -> None: + encoded = value.encode("utf-8") + if len(encoded) > 16: + raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding") + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + return cls(data.strip(bytes([0])).decode("utf-8")) + + def __bytes__(self) -> bytes: + return self.encode("utf-8") + + def __str__(self) -> str: + return core.DataType.__str__(self) + + def value_string(self) -> str: + return repr(self) + + +@dataclasses.dataclass +class EncryptedData(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.23 ENCRYPTED DATA + """ + + label = "Encrypted Data" + ad_type = core.AdvertisingData.Type.ENCRYPTED_ADVERTISING_DATA + + randomizer: int + payload: bytes + mic: bytes + + @classmethod + def from_bytes(cls, data: bytes) -> EncryptedData: + randomizer = int.from_bytes(data[:5], byteorder="little") + payload = data[5 : len(data) - 4] + mic = data[-4:] + return cls(randomizer, payload, mic) + + def __bytes__(self) -> bytes: + return self.randomizer.to_bytes(5, byteorder="little") + self.payload + self.mic + + def value_string(self) -> str: + return ( + f"randomizer=0x{self.randomizer:010X}, " + f"payload={self.payload.hex().upper()}, " + f"mic={self.mic.hex().upper()}" + ) + + +@dataclasses.dataclass +class PeriodicAdvertisingResponseTimingInformation(core.DataType): + """ + See Supplement to the Bluetooth Core Specification, Part A + 1.24 PERIODIC ADVERTISING RESPONSE TIMING INFORMATION + """ + + label = "Periodic Advertising Response Timing Information" + ad_type = core.AdvertisingData.Type.PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION + + rspaa: int + num_subevents: int + subevent_interval: int + response_slot_delay: int + response_slot_spacing: int + + @classmethod + def from_bytes(cls, data: bytes) -> PeriodicAdvertisingResponseTimingInformation: + return cls( + int.from_bytes(data[:4], byteorder="little"), + data[4], + data[5], + data[6], + data[7], + ) + + def __bytes__(self) -> bytes: + return self.rspaa.to_bytes(4, byteorder="little") + bytes( + [ + self.num_subevents, + self.subevent_interval, + self.response_slot_delay, + self.response_slot_spacing, + ] + ) + + def value_string(self) -> str: + return ( + f"rspaa=0x{self.rspaa:08X}, " + f"num_subevents={self.num_subevents}, " + f"subevent_interval={self.subevent_interval}, " + f"response_slot_delay={self.response_slot_delay}, " + f"response_slot_spacing={self.response_slot_spacing}" + ) + + +class BroadcastName(StringDataType): + """ + See Assigned Numbers, 6.12.6.13 Broadcast_Name + """ + + label = "Broadcast Name" + ad_type = core.AdvertisingData.Type.BROADCAST_NAME + + +class ResolvableSetIdentifier(FixedSizeBytesDataType): + """ + See Coordinated Set Identification Service, 3.1 RSI AD Type + """ + + label = "Resolvable Set Identifier" + ad_type = core.AdvertisingData.Type.RESOLVABLE_SET_IDENTIFIER + _fixed_size = 6 + + +# ----------------------------------------------------------------------------- +_AD_TO_DATA_TYPE_CLASS_MAP: dict[core.AdvertisingData.Type, type[core.DataType]] = { + core.AdvertisingData.Type.FLAGS: Flags, + core.AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: IncompleteListOf16BitServiceUUIDs, + core.AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: CompleteListOf16BitServiceUUIDs, + core.AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: IncompleteListOf32BitServiceUUIDs, + core.AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: CompleteListOf32BitServiceUUIDs, + core.AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: IncompleteListOf128BitServiceUUIDs, + core.AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: CompleteListOf128BitServiceUUIDs, + core.AdvertisingData.Type.SHORTENED_LOCAL_NAME: ShortenedLocalName, + core.AdvertisingData.Type.COMPLETE_LOCAL_NAME: CompleteLocalName, + core.AdvertisingData.Type.TX_POWER_LEVEL: TxPowerLevel, + core.AdvertisingData.Type.CLASS_OF_DEVICE: ClassOfDevice, + core.AdvertisingData.Type.SIMPLE_PAIRING_HASH_C_192: SecureSimplePairingHashC192, + core.AdvertisingData.Type.SIMPLE_PAIRING_RANDOMIZER_R_192: SecureSimplePairingRandomizerR192, + # core.AdvertisingData.Type.DEVICE_ID: TBD, + core.AdvertisingData.Type.SECURITY_MANAGER_TK_VALUE: SecurityManagerTKValue, + core.AdvertisingData.Type.SECURITY_MANAGER_OUT_OF_BAND_FLAGS: SecurityManagerOutOfBandFlag, + core.AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE: PeripheralConnectionIntervalRange, + core.AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: ListOf16BitServiceSolicitationUUIDs, + core.AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: ListOf128BitServiceSolicitationUUIDs, + core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID: ServiceData16BitUUID, + core.AdvertisingData.Type.PUBLIC_TARGET_ADDRESS: PublicTargetAddress, + core.AdvertisingData.Type.RANDOM_TARGET_ADDRESS: RandomTargetAddress, + core.AdvertisingData.Type.APPEARANCE: Appearance, + core.AdvertisingData.Type.ADVERTISING_INTERVAL: AdvertisingInterval, + core.AdvertisingData.Type.LE_BLUETOOTH_DEVICE_ADDRESS: LeBluetoothDeviceAddress, + core.AdvertisingData.Type.LE_ROLE: LeRole, + core.AdvertisingData.Type.SIMPLE_PAIRING_HASH_C_256: SecureSimplePairingHashC256, + core.AdvertisingData.Type.SIMPLE_PAIRING_RANDOMIZER_R_256: SecureSimplePairingRandomizerR256, + core.AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: ListOf32BitServiceSolicitationUUIDs, + core.AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID: ServiceData32BitUUID, + core.AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID: ServiceData128BitUUID, + core.AdvertisingData.Type.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: LeSecureConnectionsConfirmationValue, + core.AdvertisingData.Type.LE_SECURE_CONNECTIONS_RANDOM_VALUE: LeSecureConnectionsRandomValue, + core.AdvertisingData.Type.URI: Uri, + # core.AdvertisingData.Type.INDOOR_POSITIONING: TBD, + # core.AdvertisingData.Type.TRANSPORT_DISCOVERY_DATA: TBD, + core.AdvertisingData.Type.LE_SUPPORTED_FEATURES: LeSupportedFeatures, + core.AdvertisingData.Type.CHANNEL_MAP_UPDATE_INDICATION: ChannelMapUpdateIndication, + # core.AdvertisingData.Type.PB_ADV: TBD, + # core.AdvertisingData.Type.MESH_MESSAGE: TBD, + # core.AdvertisingData.Type.MESH_BEACON: TBD, + # core.AdvertisingData.Type.BIGINFO: BigInfo, + core.AdvertisingData.Type.BROADCAST_CODE: BroadcastCode, + core.AdvertisingData.Type.RESOLVABLE_SET_IDENTIFIER: ResolvableSetIdentifier, + core.AdvertisingData.Type.ADVERTISING_INTERVAL_LONG: AdvertisingIntervalLong, + core.AdvertisingData.Type.BROADCAST_NAME: BroadcastName, + core.AdvertisingData.Type.ENCRYPTED_ADVERTISING_DATA: EncryptedData, + core.AdvertisingData.Type.PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: PeriodicAdvertisingResponseTimingInformation, + # core.AdvertisingData.Type.ELECTRONIC_SHELF_LABEL: TBD, + # core.AdvertisingData.Type.THREE_D_INFORMATION_DATA: TBD, + core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA: ManufacturerSpecificData, +} + + +def data_type_from_advertising_data( + advertising_data_type: core.AdvertisingData.Type, + advertising_data: bytes, +) -> core.DataType: + """ + Creates a DataType object given a type ID and serialized data. + + NOTE: in general, if you know the type ID, it is preferrable to simply call the + `from_bytes` factory class method of the associated DataType class directly. + For example, use BroadcastName.from_bytes(bn_data) rather than + data_type_from_advertising_data(AdvertisingData.Type.BROADCAST_NAME, bn_data) + + Args: + advertising_data_type: type ID of the data. + advertising_data: serialized data. + + Returns: + a DataType subclass instance. + + """ + if data_type_class := _AD_TO_DATA_TYPE_CLASS_MAP.get(advertising_data_type): + return data_type_class.from_bytes(advertising_data) + + return GenericAdvertisingData(advertising_data, advertising_data_type) + + +def data_types_from_advertising_data( + advertising_data: core.AdvertisingData, +) -> list[core.DataType]: + """ + Create DataType objects representing all the advertising data structs contained + in an AdvertisingData object. + + Args: + advertising_data: the AdvertisingData in which to look for the data type. + + Returns: + a list of DataType subclass instances. + """ + return [ + data_type_from_advertising_data(ad_type, ad_data) + for (ad_type, ad_data) in advertising_data.ad_structures + ] diff --git a/bumble/device.py b/bumble/device.py index be29db0c..b777cb3d 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -47,6 +47,7 @@ from typing_extensions import Self from bumble import ( core, + data_types, gatt, gatt_client, gatt_server, @@ -1881,16 +1882,6 @@ class Connection(utils.CompositeEventEmitter): def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None: self.device.send_l2cap_pdu(self.handle, cid, pdu) - @utils.deprecated("Please use create_l2cap_channel()") - async def open_l2cap_channel( - self, - psm, - max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS, - mtu=DEVICE_DEFAULT_L2CAP_COC_MTU, - mps=DEVICE_DEFAULT_L2CAP_COC_MPS, - ): - return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps) - @overload async def create_l2cap_channel( self, spec: l2cap.ClassicChannelSpec @@ -2076,9 +2067,7 @@ class DeviceConfiguration: connectable: bool = True discoverable: bool = True advertising_data: bytes = bytes( - AdvertisingData( - [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_NAME, 'utf-8'))] - ) + AdvertisingData([data_types.CompleteLocalName(DEVICE_DEFAULT_NAME)]) ) irk: bytes = bytes(16) # This really must be changed for any level of security keystore: Optional[str] = None @@ -2122,9 +2111,7 @@ class DeviceConfiguration: self.advertising_data = bytes.fromhex(advertising_data) elif name is not None: self.advertising_data = bytes( - AdvertisingData( - [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))] - ) + AdvertisingData([data_types.CompleteLocalName(self.name)]) ) # Load scan response data @@ -2608,36 +2595,6 @@ class Device(utils.CompositeEventEmitter): None, ) - @utils.deprecated("Please use create_l2cap_server()") - def register_l2cap_server(self, psm, server) -> int: - return self.l2cap_channel_manager.register_server(psm, server) - - @utils.deprecated("Please use create_l2cap_server()") - def register_l2cap_channel_server( - self, - psm, - server, - max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS, - mtu=DEVICE_DEFAULT_L2CAP_COC_MTU, - mps=DEVICE_DEFAULT_L2CAP_COC_MPS, - ): - return self.l2cap_channel_manager.register_le_coc_server( - psm, server, max_credits, mtu, mps - ) - - @utils.deprecated("Please use create_l2cap_channel()") - async def open_l2cap_channel( - self, - connection, - psm, - max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS, - mtu=DEVICE_DEFAULT_L2CAP_COC_MTU, - mps=DEVICE_DEFAULT_L2CAP_COC_MPS, - ): - return await self.l2cap_channel_manager.open_le_coc( - connection, psm, max_credits, mtu, mps - ) - @overload async def create_l2cap_channel( self, @@ -3605,14 +3562,7 @@ class Device(utils.CompositeEventEmitter): # Synthesize an inquiry response if none is set already if self.inquiry_response is None: self.inquiry_response = bytes( - AdvertisingData( - [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes(self.name, 'utf-8'), - ) - ] - ) + AdvertisingData([data_types.CompleteLocalName(self.name)]) ) # Update the controller diff --git a/bumble/hci.py b/bumble/hci.py index 06d6cd31..9cfe81f5 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -112,7 +112,14 @@ class SpecableEnum(utils.OpenIntEnum): @classmethod def type_spec(cls, size: int): - return {'size': size, 'mapper': lambda x: cls(x).name} + return { + 'serializer': lambda x: x.to_bytes(size, 'little'), + 'parser': lambda data, offset: ( + offset + size, + cls(int.from_bytes(data[offset : offset + size], 'little')), + ), + 'mapper': lambda x: cls(x).name, + } @classmethod def type_metadata(cls, size: int, list_begin: bool = False, list_end: bool = False): @@ -123,7 +130,14 @@ class SpecableFlag(enum.IntFlag): @classmethod def type_spec(cls, size: int): - return {'size': size, 'mapper': lambda x: cls(x).name} + return { + 'serializer': lambda x: x.to_bytes(size, 'little'), + 'parser': lambda data, offset: ( + offset + size, + cls(int.from_bytes(data[offset : offset + size], 'little')), + ), + 'mapper': lambda x: cls(x).name, + } @classmethod def type_metadata(cls, size: int, list_begin: bool = False, list_end: bool = False): @@ -1322,7 +1336,7 @@ class LeFeature(SpecableEnum): MONITORING_ADVERTISERS = 64 FRAME_SPACE_UPDATE = 65 -class LeFeatureMask(enum.IntFlag): +class LeFeatureMask(utils.CompatibleIntFlag): LE_ENCRYPTION = 1 << LeFeature.LE_ENCRYPTION CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1 << LeFeature.CONNECTION_PARAMETERS_REQUEST_PROCEDURE EXTENDED_REJECT_INDICATION = 1 << LeFeature.EXTENDED_REJECT_INDICATION @@ -1463,7 +1477,7 @@ class LmpFeature(SpecableEnum): SLOT_AVAILABILITY_MASK = 138 TRAIN_NUDGING = 139 -class LmpFeatureMask(enum.IntFlag): +class LmpFeatureMask(utils.CompatibleIntFlag): # Page 0 (Legacy LMP features) LMP_3_SLOT_PACKETS = (1 << LmpFeature.LMP_3_SLOT_PACKETS) LMP_5_SLOT_PACKETS = (1 << LmpFeature.LMP_5_SLOT_PACKETS) @@ -2135,6 +2149,7 @@ class Address: if len(address) == 12 + 5: # Form with ':' separators address = address.replace(':', '') + self.address_bytes = bytes(reversed(bytes.fromhex(address))) if len(self.address_bytes) != 6: diff --git a/bumble/hid.py b/bumble/hid.py index 4a96e867..23006dec 100644 --- a/bumble/hid.py +++ b/bumble/hid.py @@ -217,33 +217,41 @@ class HID(ABC, utils.EventEmitter): self.role = role # Register ourselves with the L2CAP channel manager - device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection) - device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection) + device.create_l2cap_server( + l2cap.ClassicChannelSpec(HID_CONTROL_PSM), self.on_l2cap_connection + ) + device.create_l2cap_server( + l2cap.ClassicChannelSpec(HID_INTERRUPT_PSM), self.on_l2cap_connection + ) device.on(device.EVENT_CONNECTION, self.on_device_connection) async def connect_control_channel(self) -> None: + if not self.connection: + raise InvalidStateError("Connection is not established!") # Create a new L2CAP connection - control channel try: - channel = await self.device.l2cap_channel_manager.connect( - self.connection, HID_CONTROL_PSM + channel = await self.connection.create_l2cap_channel( + l2cap.ClassicChannelSpec(HID_CONTROL_PSM) ) channel.sink = self.on_ctrl_pdu self.l2cap_ctrl_channel = channel except ProtocolError: - logging.exception(f'L2CAP connection failed.') + logging.exception('L2CAP connection failed.') raise async def connect_interrupt_channel(self) -> None: + if not self.connection: + raise InvalidStateError("Connection is not established!") # Create a new L2CAP connection - interrupt channel try: - channel = await self.device.l2cap_channel_manager.connect( - self.connection, HID_INTERRUPT_PSM + channel = await self.connection.create_l2cap_channel( + l2cap.ClassicChannelSpec(HID_CONTROL_PSM) ) channel.sink = self.on_intr_pdu self.l2cap_intr_channel = channel except ProtocolError: - logging.exception(f'L2CAP connection failed.') + logging.exception('L2CAP connection failed.') raise async def disconnect_interrupt_channel(self) -> None: diff --git a/bumble/l2cap.py b/bumble/l2cap.py index dfe0fe37..1a5b8d38 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -1531,16 +1531,6 @@ class ChannelManager: if cid in self.fixed_channels: del self.fixed_channels[cid] - @utils.deprecated("Please use create_classic_server") - def register_server( - self, - psm: int, - server: Callable[[ClassicChannel], Any], - ) -> int: - return self.create_classic_server( - handler=server, spec=ClassicChannelSpec(psm=psm) - ).psm - def create_classic_server( self, spec: ClassicChannelSpec, @@ -1577,22 +1567,6 @@ class ChannelManager: return self.servers[spec.psm] - @utils.deprecated("Please use create_le_credit_based_server()") - def register_le_coc_server( - self, - psm: int, - server: Callable[[LeCreditBasedChannel], Any], - max_credits: int, - mtu: int, - mps: int, - ) -> int: - return self.create_le_credit_based_server( - spec=LeCreditBasedChannelSpec( - psm=None if psm == 0 else psm, mtu=mtu, mps=mps, max_credits=max_credits - ), - handler=server, - ).psm - def create_le_credit_based_server( self, spec: LeCreditBasedChannelSpec, @@ -2145,17 +2119,6 @@ class ChannelManager: if channel.source_cid in connection_channels: del connection_channels[channel.source_cid] - @utils.deprecated("Please use create_le_credit_based_channel()") - async def open_le_coc( - self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int - ) -> LeCreditBasedChannel: - return await self.create_le_credit_based_channel( - connection=connection, - spec=LeCreditBasedChannelSpec( - psm=psm, max_credits=max_credits, mtu=mtu, mps=mps - ), - ) - async def create_le_credit_based_channel( self, connection: Connection, @@ -2202,12 +2165,6 @@ class ChannelManager: return channel - @utils.deprecated("Please use create_classic_channel()") - async def connect(self, connection: Connection, psm: int) -> ClassicChannel: - return await self.create_classic_channel( - connection=connection, spec=ClassicChannelSpec(psm=psm) - ) - async def create_classic_channel( self, connection: Connection, spec: ClassicChannelSpec ) -> ClassicChannel: @@ -2244,20 +2201,3 @@ class ChannelManager: raise e return channel - - -# ----------------------------------------------------------------------------- -# Deprecated Classes -# ----------------------------------------------------------------------------- - - -class Channel(ClassicChannel): - @utils.deprecated("Please use ClassicChannel") - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - -class LeConnectionOrientedChannel(LeCreditBasedChannel): - @utils.deprecated("Please use LeCreditBasedChannel") - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) diff --git a/bumble/profiles/asha.py b/bumble/profiles/asha.py index 7ed26249..43304466 100644 --- a/bumble/profiles/asha.py +++ b/bumble/profiles/asha.py @@ -21,7 +21,7 @@ import logging import struct from typing import Any, Callable, Optional, Union -from bumble import gatt, gatt_client, l2cap, utils +from bumble import data_types, gatt, gatt_client, l2cap, utils from bumble.core import AdvertisingData from bumble.device import Connection, Device @@ -185,12 +185,11 @@ class AshaService(gatt.TemplateService): return bytes( AdvertisingData( [ - ( - AdvertisingData.SERVICE_DATA_16_BIT_UUID, - bytes(gatt.GATT_ASHA_SERVICE) - + bytes([self.protocol_version, self.capability]) + data_types.ServiceData16BitUUID( + gatt.GATT_ASHA_SERVICE, + bytes([self.protocol_version, self.capability]) + self.hisyncid[:4], - ), + ) ] ) ) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index 99bbc5ea..49c2e3d7 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -27,7 +27,7 @@ from collections.abc import Sequence from typing_extensions import Self -from bumble import core, gatt, hci, utils +from bumble import core, data_types, gatt, hci, utils from bumble.profiles import le_audio # ----------------------------------------------------------------------------- @@ -257,11 +257,10 @@ class UnicastServerAdvertisingData: return bytes( core.AdvertisingData( [ - ( - core.AdvertisingData.SERVICE_DATA_16_BIT_UUID, + data_types.ServiceData16BitUUID( + gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE, struct.pack( - '<2sBIB', - bytes(gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE), + '= 3.11 + """ + + @property + def composite_name(self) -> str: + return '|'.join( + name + for flag in self.__class__ + if self.value & flag.value and (name := flag.name) is not None + ) + + # ----------------------------------------------------------------------------- class ByteSerializable(Protocol): """ diff --git a/examples/battery_server.py b/examples/battery_server.py index 92cd87fd..ce5e0561 100644 --- a/examples/battery_server.py +++ b/examples/battery_server.py @@ -21,6 +21,7 @@ import struct import sys import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import Device from bumble.profiles.battery_service import BatteryService @@ -47,15 +48,14 @@ async def main() -> None: device.advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble Battery', 'utf-8'), + data_types.CompleteLocalName('Bumble Battery'), + data_types.IncompleteListOf16BitServiceUUIDs( + [battery_service.uuid] ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(battery_service.uuid), + data_types.Appearance( + data_types.Appearance.Category.WEARABLE_AUDIO_DEVICE, + data_types.Appearance.WearableAudioDeviceSubcategory.EARBUD, ), - (AdvertisingData.APPEARANCE, struct.pack(' None: device.advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble Device', 'utf-8'), + data_types.CompleteLocalName('Bumble Device'), + data_types.Appearance( + data_types.Appearance.Category.HEART_RATE_SENSOR, + data_types.Appearance.HeartRateSensorSubcategory.GENERIC_HEART_RATE_SENSOR, ), - (AdvertisingData.APPEARANCE, struct.pack(' None: device.advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble Heart', 'utf-8'), + data_types.CompleteLocalName('Bumble Heart'), + data_types.IncompleteListOf16BitServiceUUIDs( + [heart_rate_service.uuid] ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(heart_rate_service.uuid), + data_types.Appearance( + data_types.Appearance.Category.HEART_RATE_SENSOR, + data_types.Appearance.HeartRateSensorSubcategory.GENERIC_HEART_RATE_SENSOR, ), - (AdvertisingData.APPEARANCE, struct.pack(' None: device.scan_response_data = bytes( AdvertisingData( [ - (AdvertisingData.APPEARANCE, struct.pack(' None: bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes(device.name, 'utf-8'), - ), - (AdvertisingData.FLAGS, bytes([0x06])), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(gatt.GATT_ASHA_SERVICE), + data_types.CompleteLocalName(device.name), + data_types.Flags(AdvertisingData.Flags(0x06)), + data_types.IncompleteListOf16BitServiceUUIDs( + [gatt.GATT_ASHA_SERVICE] ), ] ) diff --git a/examples/run_csis_servers.py b/examples/run_csis_servers.py index 78fac893..ea609fbf 100644 --- a/examples/run_csis_servers.py +++ b/examples/run_csis_servers.py @@ -20,6 +20,7 @@ import secrets import sys import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import Device from bumble.hci import Address @@ -66,23 +67,14 @@ async def main() -> None: bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes(f'Bumble LE Audio-{i}', 'utf-8'), + data_types.CompleteLocalName(f'Bumble LE Audio-{i}'), + data_types.Flags( + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG ), - ( - AdvertisingData.FLAGS, - bytes( - [ - AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG - | AdvertisingData.BR_EDR_HOST_FLAG - | AdvertisingData.BR_EDR_CONTROLLER_FLAG - ] - ), - ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(CoordinatedSetIdentificationService.UUID), + data_types.IncompleteListOf16BitServiceUUIDs( + [CoordinatedSetIdentificationService.UUID] ), ] ) diff --git a/examples/run_hap_server.py b/examples/run_hap_server.py index c30ea14b..50947668 100644 --- a/examples/run_hap_server.py +++ b/examples/run_hap_server.py @@ -19,6 +19,7 @@ import asyncio import sys import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import Device from bumble.profiles.hap import ( @@ -71,23 +72,14 @@ async def main() -> None: advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble HearingAccessService', 'utf-8'), + data_types.CompleteLocalName('Bumble HearingAccessService'), + data_types.Flags( + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG ), - ( - AdvertisingData.FLAGS, - bytes( - [ - AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG - | AdvertisingData.BR_EDR_HOST_FLAG - | AdvertisingData.BR_EDR_CONTROLLER_FLAG - ] - ), - ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(HearingAccessService.UUID), + data_types.IncompleteListOf16BitServiceUUIDs( + [HearingAccessService.UUID] ), ] ) diff --git a/examples/run_mcp_client.py b/examples/run_mcp_client.py index db72ca6e..fb5c34a1 100644 --- a/examples/run_mcp_client.py +++ b/examples/run_mcp_client.py @@ -23,6 +23,7 @@ from typing import Optional import websockets import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import ( AdvertisingEventProperties, @@ -106,17 +107,10 @@ async def main() -> None: advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble LE Audio', 'utf-8'), - ), - ( - AdvertisingData.FLAGS, - bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]), - ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(PublishedAudioCapabilitiesService.UUID), + data_types.CompleteLocalName('Bumble LE Audio'), + data_types.Flags(AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG), + data_types.IncompleteListOf16BitServiceUUIDs( + [PublishedAudioCapabilitiesService.UUID] ), ] ) diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py index fb2c6a08..6d4eae02 100644 --- a/examples/run_unicast_server.py +++ b/examples/run_unicast_server.py @@ -24,6 +24,7 @@ import struct import sys import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import Device from bumble.hci import CodecID, CodingFormat, HCI_IsoDataPacket @@ -111,23 +112,14 @@ async def main() -> None: bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble LE Audio', 'utf-8'), + data_types.CompleteLocalName('Bumble LE Audio'), + data_types.Flags( + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG ), - ( - AdvertisingData.FLAGS, - bytes( - [ - AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG - | AdvertisingData.BR_EDR_HOST_FLAG - | AdvertisingData.BR_EDR_CONTROLLER_FLAG - ] - ), - ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(PublishedAudioCapabilitiesService.UUID), + data_types.IncompleteListOf16BitServiceUUIDs( + [PublishedAudioCapabilitiesService.UUID] ), ] ) diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py index c2b52acd..4217ac8b 100644 --- a/examples/run_vcp_renderer.py +++ b/examples/run_vcp_renderer.py @@ -24,6 +24,7 @@ from typing import Optional import websockets import bumble.logging +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import AdvertisingEventProperties, AdvertisingParameters, Device from bumble.hci import CodecID, CodingFormat, OwnAddressType @@ -127,23 +128,14 @@ async def main() -> None: bytes( AdvertisingData( [ - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble LE Audio', 'utf-8'), + data_types.CompleteLocalName('Bumble LE Audio'), + data_types.Flags( + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG ), - ( - AdvertisingData.FLAGS, - bytes( - [ - AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG - | AdvertisingData.BR_EDR_HOST_FLAG - | AdvertisingData.BR_EDR_CONTROLLER_FLAG - ] - ), - ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(PublishedAudioCapabilitiesService.UUID), + data_types.IncompleteListOf16BitServiceUUIDs( + [PublishedAudioCapabilitiesService.UUID] ), ] ) diff --git a/tests/avrcp_test.py b/tests/avrcp_test.py index dfc0bb31..5769eb17 100644 --- a/tests/avrcp_test.py +++ b/tests/avrcp_test.py @@ -15,67 +15,210 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- -import asyncio +from __future__ import annotations + import struct +from collections.abc import Sequence import pytest -from bumble import avc, avctp, avrcp, controller, core, device, host, link -from bumble.transport import common +from bumble import avc, avctp, avrcp + +from . import test_utils # ----------------------------------------------------------------------------- -class TwoDevices: - def __init__(self): - self.connections = [None, None] - - addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0'] - self.link = link.LocalLink() - self.controllers = [ - controller.Controller('C1', link=self.link, public_address=addresses[0]), - controller.Controller('C2', link=self.link, public_address=addresses[1]), - ] - self.devices = [ - device.Device( - address=addresses[0], - host=host.Host( - self.controllers[0], common.AsyncPipeSink(self.controllers[0]) - ), - ), - device.Device( - address=addresses[1], - host=host.Host( - self.controllers[1], common.AsyncPipeSink(self.controllers[1]) - ), - ), - ] - self.devices[0].classic_enabled = True - self.devices[1].classic_enabled = True - self.connections = [None, None] - self.protocols = [None, None] - - def on_connection(self, which, connection): - self.connections[which] = connection - - async def setup_connections(self): - await self.devices[0].power_on() - await self.devices[1].power_on() - - self.connections = await asyncio.gather( - self.devices[0].connect( - self.devices[1].public_address, core.PhysicalTransport.BR_EDR - ), - self.devices[1].accept(self.devices[0].public_address), - ) +class TwoDevices(test_utils.TwoDevices): + protocols: Sequence[avrcp.Protocol] = () + async def setup_avdtp_connections(self): self.protocols = [avrcp.Protocol(), avrcp.Protocol()] self.protocols[0].listen(self.devices[1]) await self.protocols[1].connect(self.connections[0]) + @classmethod + async def create_with_avdtp(cls) -> TwoDevices: + devices = await cls.create_with_connection() + await devices.setup_avdtp_connections() + return devices + + +# ----------------------------------------------------------------------------- +def test_GetPlayStatusCommand(): + command = avrcp.GetPlayStatusCommand() + assert avrcp.Command.from_bytes(command.pdu_id, bytes(command)) == command + + +# ----------------------------------------------------------------------------- +def test_GetCapabilitiesCommand(): + command = avrcp.GetCapabilitiesCommand( + capability_id=avrcp.GetCapabilitiesCommand.CapabilityId.COMPANY_ID + ) + assert avrcp.Command.from_bytes(command.pdu_id, bytes(command)) == command + + +# ----------------------------------------------------------------------------- +def test_SetAbsoluteVolumeCommand(): + command = avrcp.SetAbsoluteVolumeCommand(volume=5) + assert avrcp.Command.from_bytes(command.pdu_id, bytes(command)) == command + + +# ----------------------------------------------------------------------------- +def test_GetElementAttributesCommand(): + command = avrcp.GetElementAttributesCommand( + identifier=999, + attribute_ids=[ + avrcp.MediaAttributeId.ALBUM_NAME, + avrcp.MediaAttributeId.ARTIST_NAME, + ], + ) + assert avrcp.Command.from_bytes(command.pdu_id, bytes(command)) == command + + +# ----------------------------------------------------------------------------- +def test_RegisterNotificationCommand(): + command = avrcp.RegisterNotificationCommand( + event_id=avrcp.EventId.ADDRESSED_PLAYER_CHANGED, playback_interval=123 + ) + assert avrcp.Command.from_bytes(command.pdu_id, bytes(command)) == command + + +# ----------------------------------------------------------------------------- +def test_UidsChangedEvent(): + event = avrcp.UidsChangedEvent(uid_counter=7) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_TrackChangedEvent(): + event = avrcp.TrackChangedEvent(identifier=b'12356') + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_VolumeChangedEvent(): + event = avrcp.VolumeChangedEvent(volume=9) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_PlaybackStatusChangedEvent(): + event = avrcp.PlaybackStatusChangedEvent(play_status=avrcp.PlayStatus.PLAYING) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_AddressedPlayerChangedEvent(): + event = avrcp.AddressedPlayerChangedEvent( + player=avrcp.AddressedPlayerChangedEvent.Player(player_id=9, uid_counter=10) + ) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_AvailablePlayersChangedEvent(): + event = avrcp.AvailablePlayersChangedEvent() + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_PlaybackPositionChangedEvent(): + event = avrcp.PlaybackPositionChangedEvent(playback_position=1314) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_NowPlayingContentChangedEvent(): + event = avrcp.NowPlayingContentChangedEvent() + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_PlayerApplicationSettingChangedEvent(): + event = avrcp.PlayerApplicationSettingChangedEvent( + player_application_settings=[ + avrcp.PlayerApplicationSettingChangedEvent.Setting( + avrcp.ApplicationSetting.AttributeId.REPEAT_MODE, + avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT, + ) + ] + ) + assert avrcp.Event.from_bytes(bytes(event)) == event + + +# ----------------------------------------------------------------------------- +def test_RejectedResponse(): + pdu_id = avrcp.PduId.GET_ELEMENT_ATTRIBUTES + response = avrcp.RejectedResponse( + pdu_id=pdu_id, + status_code=avrcp.StatusCode.DOES_NOT_EXIST, + ) + assert ( + avrcp.RejectedResponse.from_bytes(pdu=bytes(response), pdu_id=pdu_id) + == response + ) + + +# ----------------------------------------------------------------------------- +def test_GetPlayStatusResponse(): + response = avrcp.GetPlayStatusResponse( + song_length=1010, song_position=13, play_status=avrcp.PlayStatus.PAUSED + ) + assert avrcp.GetPlayStatusResponse.from_bytes(bytes(response)) == response + + +# ----------------------------------------------------------------------------- +def test_NotImplementedResponse(): + pdu_id = avrcp.PduId.GET_ELEMENT_ATTRIBUTES + response = avrcp.NotImplementedResponse(pdu_id=pdu_id, parameters=b'koasd') + assert ( + avrcp.NotImplementedResponse.from_bytes(bytes(response), pdu_id=pdu_id) + == response + ) + + +# ----------------------------------------------------------------------------- +def test_GetCapabilitiesResponse(): + response = avrcp.GetCapabilitiesResponse( + capability_id=avrcp.GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED, + capabilities=[ + avrcp.EventId.ADDRESSED_PLAYER_CHANGED, + avrcp.EventId.BATT_STATUS_CHANGED, + ], + ) + assert avrcp.GetCapabilitiesResponse.from_bytes(bytes(response)) == response + + +# ----------------------------------------------------------------------------- +def test_RegisterNotificationResponse(): + response = avrcp.RegisterNotificationResponse( + event=avrcp.PlaybackPositionChangedEvent(playback_position=38) + ) + assert avrcp.RegisterNotificationResponse.from_bytes(bytes(response)) == response + + +# ----------------------------------------------------------------------------- +def test_SetAbsoluteVolumeResponse(): + response = avrcp.SetAbsoluteVolumeResponse(volume=99) + assert avrcp.SetAbsoluteVolumeResponse.from_bytes(bytes(response)) == response + + +# ----------------------------------------------------------------------------- +def test_GetElementAttributesResponse(): + response = avrcp.GetElementAttributesResponse( + attributes=[ + avrcp.MediaAttribute( + attribute_id=avrcp.MediaAttributeId.ALBUM_NAME, + attribute_value="White Album", + ) + ] + ) + assert avrcp.GetElementAttributesResponse.from_bytes(bytes(response)) == response + # ----------------------------------------------------------------------------- def test_frame_parser(): - with pytest.raises(ValueError) as error: + with pytest.raises(ValueError): avc.Frame.from_bytes(bytes.fromhex("11480000")) x = bytes.fromhex("014D0208") @@ -217,8 +360,7 @@ def test_passthrough_commands(): # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_supported_events(): - two_devices = TwoDevices() - await two_devices.setup_connections() + two_devices = await TwoDevices.create_with_avdtp() supported_events = await two_devices.protocols[0].get_supported_events() assert supported_events == [] diff --git a/tests/core_test.py b/tests/core_test.py index a77be12b..02ebbaab 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -16,7 +16,13 @@ # Imports # ----------------------------------------------------------------------------- -from bumble.core import UUID, AdvertisingData, Appearance, get_dict_key_by_value +from bumble.core import ( + UUID, + AdvertisingData, + Appearance, + ClassOfDevice, + get_dict_key_by_value, +) # ----------------------------------------------------------------------------- @@ -93,6 +99,24 @@ def test_appearance() -> None: assert int(a) == 0x3333 +# ----------------------------------------------------------------------------- +def test_class_of_device() -> None: + c1 = ClassOfDevice( + ClassOfDevice.MajorServiceClasses.AUDIO + | ClassOfDevice.MajorServiceClasses.RENDERING, + ClassOfDevice.MajorDeviceClass.AUDIO_VIDEO, + ClassOfDevice.AudioVideoMinorDeviceClass.CAMCORDER, + ) + assert str(c1) == "ClassOfDevice(RENDERING|AUDIO,AUDIO_VIDEO/CAMCORDER)" + + c2 = ClassOfDevice( + ClassOfDevice.MajorServiceClasses.AUDIO, + ClassOfDevice.MajorDeviceClass.AUDIO_VIDEO, + 0x123, + ) + assert str(c2) == "ClassOfDevice(AUDIO,AUDIO_VIDEO/0x123)" + + # ----------------------------------------------------------------------------- if __name__ == '__main__': test_ad_data() diff --git a/web/heart_rate_monitor/heart_rate_monitor.py b/web/heart_rate_monitor/heart_rate_monitor.py index 4a843b46..4ba8ef54 100644 --- a/web/heart_rate_monitor/heart_rate_monitor.py +++ b/web/heart_rate_monitor/heart_rate_monitor.py @@ -17,6 +17,7 @@ # ----------------------------------------------------------------------------- import struct +from bumble import data_types from bumble.core import AdvertisingData from bumble.device import Device from bumble.hci import HCI_Reset_Command @@ -65,24 +66,18 @@ class HeartRateMonitor: self.device.advertising_data = bytes( AdvertisingData( [ - ( - AdvertisingData.FLAGS, - bytes( - [ - AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG - | AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG - ] - ), + data_types.Flags( + AdvertisingData.Flags.LE_GENERAL_DISCOVERABLE_MODE + | AdvertisingData.Flags.BR_EDR_NOT_SUPPORTED ), - ( - AdvertisingData.COMPLETE_LOCAL_NAME, - bytes('Bumble Heart', 'utf-8'), + data_types.CompleteLocalName('Bumble Heart'), + data_types.IncompleteListOf16BitServiceUUIDs( + [self.heart_rate_service.uuid] ), - ( - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - bytes(self.heart_rate_service.uuid), + data_types.Appearance( + data_types.Appearance.Category.HEART_RATE_SENSOR, + data_types.Appearance.HeartRateSensorSubcategory.GENERIC_HEART_RATE_SENSOR, ), - (AdvertisingData.APPEARANCE, struct.pack('