Coverage for src / bluetooth_sig / types / advertising / transport_discovery.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Transport Discovery Data (AD 0x26, CSS Part A §1.10). 

2 

3Decodes the Transport Discovery Data AD type which carries one or more 

4transport blocks describing available transport connections. 

5""" 

6 

7from __future__ import annotations 

8 

9from enum import IntFlag 

10 

11import msgspec 

12 

13from bluetooth_sig.gatt.characteristics.utils import DataParser 

14 

15# Transport block header: org_id (1) + flags (1) + data_length (1) 

16TRANSPORT_BLOCK_HEADER_LENGTH = 3 

17 

18 

19class TDSFlags(IntFlag): 

20 """Transport Discovery Service flags (CSS Part A §1.10). 

21 

22 Encoded in a single byte per transport block. 

23 """ 

24 

25 ROLE_NOT_SPECIFIED = 0x00 

26 ROLE_SEEKER = 0x01 

27 ROLE_PROVIDER = 0x02 

28 ROLE_SEEKER_AND_PROVIDER = 0x03 

29 INCOMPLETE = 0x04 # Bit 2: transport data is incomplete 

30 STATE_OFF = 0x00 # Bits 3-4 = 0b00 

31 STATE_ON = 0x08 # Bits 3-4 = 0b01 

32 STATE_TEMPORARILY_UNAVAILABLE = 0x10 # Bits 3-4 = 0b10 

33 

34 

35# Masks for extracting sub-fields from TDSFlags 

36TDS_ROLE_MASK = TDSFlags.ROLE_SEEKER | TDSFlags.ROLE_PROVIDER 

37TDS_STATE_MASK = TDSFlags.STATE_ON | TDSFlags.STATE_TEMPORARILY_UNAVAILABLE 

38 

39 

40class TransportBlock(msgspec.Struct, frozen=True, kw_only=True): 

41 """A single transport block within Transport Discovery Data. 

42 

43 Attributes: 

44 organization_id: Organisation defining the transport data (1 = Bluetooth SIG). 

45 flags: TDS flags — role, incomplete, and transport state. 

46 transport_data: Organisation-specific transport payload. 

47 

48 """ 

49 

50 organization_id: int 

51 flags: TDSFlags 

52 transport_data: bytes = b"" 

53 

54 @property 

55 def role(self) -> TDSFlags: 

56 """Role bits (0-1): seeker, provider, both, or not specified.""" 

57 return self.flags & TDS_ROLE_MASK 

58 

59 @property 

60 def is_incomplete(self) -> bool: 

61 """Whether transport data is incomplete (bit 2).""" 

62 return bool(self.flags & TDSFlags.INCOMPLETE) 

63 

64 @property 

65 def transport_state(self) -> TDSFlags: 

66 """Transport state (bits 3-4): off, on, or temporarily unavailable.""" 

67 return self.flags & TDS_STATE_MASK 

68 

69 

70class TransportDiscoveryData(msgspec.Struct, frozen=True, kw_only=True): 

71 """Transport Discovery Data (CSS Part A, §1.10). 

72 

73 Contains one or more transport blocks describing available transport 

74 connections (e.g. Wi-Fi, classic Bluetooth). 

75 

76 Attributes: 

77 blocks: List of parsed transport blocks. 

78 

79 """ 

80 

81 blocks: list[TransportBlock] = msgspec.field(default_factory=list) 

82 

83 @classmethod 

84 def decode(cls, data: bytes | bytearray) -> TransportDiscoveryData: 

85 """Decode Transport Discovery Data AD. 

86 

87 Iterates over transport blocks until the buffer is exhausted. 

88 DataParser raises ``InsufficientDataError`` if a block header is 

89 truncated. Incomplete trailing blocks (fewer than 

90 ``TRANSPORT_BLOCK_HEADER_LENGTH`` bytes remaining) are silently 

91 skipped, matching real-world scanner behaviour. 

92 

93 Args: 

94 data: Raw AD data bytes (excluding length and AD type). 

95 

96 Returns: 

97 Parsed TransportDiscoveryData with transport blocks. 

98 

99 """ 

100 blocks: list[TransportBlock] = [] 

101 offset = 0 

102 

103 while offset + TRANSPORT_BLOCK_HEADER_LENGTH <= len(data): 

104 org_id = DataParser.parse_int8(data, offset, signed=False) 

105 tds_flags = TDSFlags(DataParser.parse_int8(data, offset + 1, signed=False)) 

106 transport_data_length = DataParser.parse_int8(data, offset + 2, signed=False) 

107 offset += TRANSPORT_BLOCK_HEADER_LENGTH 

108 

109 end = min(offset + transport_data_length, len(data)) 

110 transport_data = bytes(data[offset:end]) 

111 offset = end 

112 

113 blocks.append( 

114 TransportBlock( 

115 organization_id=org_id, 

116 flags=tds_flags, 

117 transport_data=transport_data, 

118 ) 

119 ) 

120 

121 return cls(blocks=blocks) 

122 

123 

124__all__ = [ 

125 "TDSFlags", 

126 "TDS_ROLE_MASK", 

127 "TDS_STATE_MASK", 

128 "TRANSPORT_BLOCK_HEADER_LENGTH", 

129 "TransportBlock", 

130 "TransportDiscoveryData", 

131]