Coverage for src / bluetooth_sig / gatt / descriptors / characteristic_presentation_format.py: 92%

111 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

1"""Characteristic Presentation Format Descriptor implementation.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum 

6 

7import msgspec 

8 

9from ...registry.core.formattypes import format_types_registry 

10from ...registry.core.namespace_description import namespace_description_registry 

11from ...registry.uuids.units import units_registry 

12from ...types.uuid import BluetoothUUID 

13from ..characteristics.utils import DataParser 

14from .base import BaseDescriptor 

15 

16 

17class FormatNamespace(IntEnum): 

18 """Format namespace values for Characteristic Presentation Format.""" 

19 

20 UNKNOWN = 0x00 

21 BLUETOOTH_SIG_ASSIGNED_NUMBERS = 0x01 

22 RESERVED = 0x02 

23 

24 @classmethod 

25 def _missing_(cls, value: object) -> FormatNamespace: 

26 """Return UNKNOWN for unrecognised namespace values.""" 

27 if not isinstance(value, int): 

28 return None # type: ignore[return-value] 

29 obj = int.__new__(cls, value) 

30 obj._name_ = f"UNKNOWN_{value}" 

31 obj._value_ = value 

32 return obj 

33 

34 

35class FormatType(IntEnum): 

36 """Format type values for Characteristic Presentation Format.""" 

37 

38 # Reserved/Unknown 

39 UNKNOWN = 0x00 

40 

41 # Common Bluetooth SIG format types 

42 BOOLEAN = 0x01 

43 UINT2 = 0x02 

44 UINT4 = 0x03 

45 UINT8 = 0x04 

46 UINT12 = 0x05 

47 UINT16 = 0x06 

48 UINT24 = 0x07 

49 UINT32 = 0x08 

50 UINT48 = 0x09 

51 UINT64 = 0x0A 

52 UINT128 = 0x0B 

53 SINT8 = 0x0C 

54 SINT12 = 0x0D 

55 SINT16 = 0x0E 

56 SINT24 = 0x0F 

57 SINT32 = 0x10 

58 SINT48 = 0x11 

59 SINT64 = 0x12 

60 SINT128 = 0x13 

61 FLOAT32 = 0x14 

62 FLOAT64 = 0x15 

63 SFLOAT = 0x16 

64 FLOAT = 0x17 

65 DUINT16 = 0x18 

66 UTF8S = 0x19 

67 UTF16S = 0x1A 

68 STRUCT = 0x1B 

69 

70 @classmethod 

71 def _missing_(cls, value: object) -> FormatType: 

72 """Return dynamically created member for unrecognised format values.""" 

73 if not isinstance(value, int): 

74 return None # type: ignore[return-value] 

75 obj = int.__new__(cls, value) 

76 obj._name_ = f"UNKNOWN_{value}" 

77 obj._value_ = value 

78 return obj 

79 

80 

81class CharacteristicPresentationFormatData(msgspec.Struct, frozen=True, kw_only=True): 

82 """Characteristic Presentation Format descriptor data. 

83 

84 Raw integer values are preserved for protocol compatibility. 

85 Resolved names are provided when available via registry lookups. 

86 """ 

87 

88 format: FormatType 

89 """Format type value (e.g., FormatType.UINT16).""" 

90 format_name: str | None = None 

91 """Resolved format type name (e.g., 'uint16') from FormatTypesRegistry.""" 

92 exponent: int 

93 """Base 10 exponent for scaling (-128 to 127).""" 

94 unit: int 

95 """Raw unit UUID value (16-bit short form, e.g., 0x272F for Celsius).""" 

96 unit_name: str | None = None 

97 """Resolved unit name (e.g., 'degree Celsius') from UnitsRegistry.""" 

98 namespace: FormatNamespace 

99 """Namespace for description field (e.g., FormatNamespace.BLUETOOTH_SIG_ASSIGNED_NUMBERS).""" 

100 description: int 

101 """Description identifier within the namespace.""" 

102 description_name: str | None = None 

103 """Resolved description name (e.g., 'left', 'first') from NamespaceDescriptionRegistry. 

104 

105 Only resolved when namespace=0x01 (Bluetooth SIG Assigned Numbers). 

106 """ 

107 

108 

109class CharacteristicPresentationFormatDescriptor(BaseDescriptor): 

110 """Characteristic Presentation Format Descriptor (0x2904). 

111 

112 Describes how characteristic values should be presented to users. 

113 Contains format, exponent, unit, namespace, and description information. 

114 """ 

115 

116 def _has_structured_data(self) -> bool: 

117 return True 

118 

119 def _get_data_format(self) -> str: 

120 return "struct" 

121 

122 def _parse_descriptor_value(self, data: bytes) -> CharacteristicPresentationFormatData: 

123 """Parse Characteristic Presentation Format value. 

124 

125 Format: 7 bytes 

126 - Format (1 byte): Data type format 

127 - Exponent (1 byte): Base 10 exponent (-128 to 127) 

128 - Unit (2 bytes): Unit of measurement (little-endian) 

129 - Namespace (1 byte): Namespace for description 

130 - Description (2 bytes): Description identifier (little-endian) 

131 

132 Args: 

133 data: Raw bytes (should be 7 bytes) 

134 

135 Returns: 

136 CharacteristicPresentationFormatData with format information 

137 

138 Raises: 

139 ValueError: If data is not exactly 7 bytes 

140 """ 

141 if len(data) != 7: 

142 raise ValueError(f"Characteristic Presentation Format data must be exactly 7 bytes, got {len(data)}") 

143 

144 format_val = DataParser.parse_int8(data, offset=0) 

145 namespace_val = DataParser.parse_int8(data, offset=4) 

146 unit_val = DataParser.parse_int16(data, offset=2, endian="little") 

147 description_val = DataParser.parse_int16(data, offset=5, endian="little") 

148 

149 # Resolve format type name from registry 

150 format_info = format_types_registry.get_format_type_info(format_val) 

151 format_name = format_info.short_name if format_info else None 

152 

153 # Resolve unit name from registry (unit is stored as 16-bit UUID) 

154 unit_uuid = BluetoothUUID(unit_val) 

155 unit_info = units_registry.get_unit_info(unit_uuid) 

156 unit_name = unit_info.name if unit_info else None 

157 

158 # Resolve description name from registry (only for Bluetooth SIG namespace) 

159 description_name: str | None = None 

160 if namespace_val == FormatNamespace.BLUETOOTH_SIG_ASSIGNED_NUMBERS: 

161 description_name = namespace_description_registry.resolve_description_name(description_val) 

162 

163 return CharacteristicPresentationFormatData( 

164 format=FormatType(format_val), 

165 format_name=format_name, 

166 exponent=DataParser.parse_int8(data, offset=1, signed=True), 

167 unit=unit_val, 

168 unit_name=unit_name, 

169 namespace=FormatNamespace(namespace_val), 

170 description=description_val, 

171 description_name=description_name, 

172 ) 

173 

174 def get_format_type(self, data: bytes) -> FormatType: 

175 """Get the format type.""" 

176 parsed = self._parse_descriptor_value(data) 

177 return parsed.format 

178 

179 def get_exponent(self, data: bytes) -> int: 

180 """Get the exponent for scaling.""" 

181 parsed = self._parse_descriptor_value(data) 

182 return parsed.exponent 

183 

184 def get_unit(self, data: bytes) -> int: 

185 """Get the unit identifier.""" 

186 parsed = self._parse_descriptor_value(data) 

187 return parsed.unit 

188 

189 def get_namespace(self, data: bytes) -> FormatNamespace: 

190 """Get the namespace identifier.""" 

191 parsed = self._parse_descriptor_value(data) 

192 return parsed.namespace 

193 

194 def get_description(self, data: bytes) -> int: 

195 """Get the description identifier.""" 

196 parsed = self._parse_descriptor_value(data) 

197 return parsed.description