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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Characteristic Presentation Format Descriptor implementation."""
3from __future__ import annotations
5from enum import IntEnum
7import msgspec
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
17class FormatNamespace(IntEnum):
18 """Format namespace values for Characteristic Presentation Format."""
20 UNKNOWN = 0x00
21 BLUETOOTH_SIG_ASSIGNED_NUMBERS = 0x01
22 RESERVED = 0x02
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
35class FormatType(IntEnum):
36 """Format type values for Characteristic Presentation Format."""
38 # Reserved/Unknown
39 UNKNOWN = 0x00
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
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
81class CharacteristicPresentationFormatData(msgspec.Struct, frozen=True, kw_only=True):
82 """Characteristic Presentation Format descriptor data.
84 Raw integer values are preserved for protocol compatibility.
85 Resolved names are provided when available via registry lookups.
86 """
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.
105 Only resolved when namespace=0x01 (Bluetooth SIG Assigned Numbers).
106 """
109class CharacteristicPresentationFormatDescriptor(BaseDescriptor):
110 """Characteristic Presentation Format Descriptor (0x2904).
112 Describes how characteristic values should be presented to users.
113 Contains format, exponent, unit, namespace, and description information.
114 """
116 def _has_structured_data(self) -> bool:
117 return True
119 def _get_data_format(self) -> str:
120 return "struct"
122 def _parse_descriptor_value(self, data: bytes) -> CharacteristicPresentationFormatData:
123 """Parse Characteristic Presentation Format value.
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)
132 Args:
133 data: Raw bytes (should be 7 bytes)
135 Returns:
136 CharacteristicPresentationFormatData with format information
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)}")
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")
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
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
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)
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 )
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
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
184 def get_unit(self, data: bytes) -> int:
185 """Get the unit identifier."""
186 parsed = self._parse_descriptor_value(data)
187 return parsed.unit
189 def get_namespace(self, data: bytes) -> FormatNamespace:
190 """Get the namespace identifier."""
191 parsed = self._parse_descriptor_value(data)
192 return parsed.namespace
194 def get_description(self, data: bytes) -> int:
195 """Get the description identifier."""
196 parsed = self._parse_descriptor_value(data)
197 return parsed.description