Coverage for src / bluetooth_sig / gatt / characteristics / utils / extractors.py: 90%
153 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"""Raw byte extractors for the BLE encoding/decoding pipeline.
3This module provides the extraction layer that ONLY converts bytes to raw integers
4(and back). Extractors have a single responsibility: byte layout interpretation.
6The extraction layer is the first stage of the decode pipeline:
7 bytes → [Extractor] → raw_int → [Translator] → typed_value
9Per Bluetooth SIG specifications, all multi-byte values use little-endian encoding
10unless explicitly stated otherwise.
11"""
13from __future__ import annotations
15from abc import ABC, abstractmethod
16from typing import Literal
18from .data_parser import DataParser
21class RawExtractor(ABC):
22 """Protocol for raw byte extraction.
24 Extractors handle ONLY byte layout: extracting raw integers from bytes
25 and packing raw integers back to bytes. They do not apply scaling,
26 handle special values, or perform validation beyond bounds checking.
28 The separation enables:
29 - Interception of raw values for special value handling
30 - Composition with translators for scaling
31 - Reuse across templates and characteristics
32 """
34 __slots__ = ()
36 @property
37 @abstractmethod
38 def byte_size(self) -> int:
39 """Number of bytes this extractor reads/writes."""
41 @property
42 @abstractmethod
43 def signed(self) -> bool:
44 """Whether the integer type is signed."""
46 @abstractmethod
47 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
48 """Extract raw integer from bytes.
50 Args:
51 data: Source bytes to extract from.
52 offset: Byte offset to start reading from.
54 Returns:
55 Raw integer value (not scaled or interpreted).
57 Raises:
58 InsufficientDataError: If data is too short for extraction.
59 """
61 @abstractmethod
62 def pack(self, raw: int) -> bytearray:
63 """Pack raw integer to bytes.
65 Args:
66 raw: Raw integer value to encode.
68 Returns:
69 Packed bytes in little-endian format.
71 Raises:
72 ValueRangeError: If raw value exceeds type bounds.
73 """
76class Uint8Extractor(RawExtractor):
77 """Extract/pack unsigned 8-bit integers (0 to 255)."""
79 __slots__ = ()
81 @property
82 def byte_size(self) -> int:
83 """Size: 1 byte."""
84 return 1
86 @property
87 def signed(self) -> bool:
88 """Unsigned type."""
89 return False
91 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
92 """Extract uint8 from bytes."""
93 return DataParser.parse_int8(data, offset, signed=False)
95 def pack(self, raw: int) -> bytearray:
96 """Pack uint8 to bytes."""
97 return DataParser.encode_int8(raw, signed=False)
100class Sint8Extractor(RawExtractor):
101 """Extract/pack signed 8-bit integers (-128 to 127)."""
103 __slots__ = ()
105 @property
106 def byte_size(self) -> int:
107 """Size: 1 byte."""
108 return 1
110 @property
111 def signed(self) -> bool:
112 """Signed type."""
113 return True
115 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
116 """Extract sint8 from bytes."""
117 return DataParser.parse_int8(data, offset, signed=True)
119 def pack(self, raw: int) -> bytearray:
120 """Pack sint8 to bytes."""
121 return DataParser.encode_int8(raw, signed=True)
124class Uint16Extractor(RawExtractor):
125 """Extract/pack unsigned 16-bit integers (0 to 65535)."""
127 __slots__ = ("_endian",)
129 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
130 """Initialize with endianness.
132 Args:
133 endian: Byte order, defaults to little-endian per BLE spec.
134 """
135 self._endian: Literal["little", "big"] = endian
137 @property
138 def byte_size(self) -> int:
139 """Size: 2 bytes."""
140 return 2
142 @property
143 def signed(self) -> bool:
144 """Unsigned type."""
145 return False
147 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
148 """Extract uint16 from bytes."""
149 return DataParser.parse_int16(data, offset, signed=False, endian=self._endian)
151 def pack(self, raw: int) -> bytearray:
152 """Pack uint16 to bytes."""
153 return DataParser.encode_int16(raw, signed=False, endian=self._endian)
156class Sint16Extractor(RawExtractor):
157 """Extract/pack signed 16-bit integers (-32768 to 32767)."""
159 __slots__ = ("_endian",)
161 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
162 """Initialize with endianness.
164 Args:
165 endian: Byte order, defaults to little-endian per BLE spec.
166 """
167 self._endian: Literal["little", "big"] = endian
169 @property
170 def byte_size(self) -> int:
171 """Size: 2 bytes."""
172 return 2
174 @property
175 def signed(self) -> bool:
176 """Signed type."""
177 return True
179 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
180 """Extract sint16 from bytes."""
181 return DataParser.parse_int16(data, offset, signed=True, endian=self._endian)
183 def pack(self, raw: int) -> bytearray:
184 """Pack sint16 to bytes."""
185 return DataParser.encode_int16(raw, signed=True, endian=self._endian)
188class Uint24Extractor(RawExtractor):
189 """Extract/pack unsigned 24-bit integers (0 to 16777215)."""
191 __slots__ = ("_endian",)
193 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
194 """Initialize with endianness.
196 Args:
197 endian: Byte order, defaults to little-endian per BLE spec.
198 """
199 self._endian: Literal["little", "big"] = endian
201 @property
202 def byte_size(self) -> int:
203 """Size: 3 bytes."""
204 return 3
206 @property
207 def signed(self) -> bool:
208 """Unsigned type."""
209 return False
211 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
212 """Extract uint24 from bytes."""
213 return DataParser.parse_int24(data, offset, signed=False, endian=self._endian)
215 def pack(self, raw: int) -> bytearray:
216 """Pack uint24 to bytes."""
217 return DataParser.encode_int24(raw, signed=False, endian=self._endian)
220class Sint24Extractor(RawExtractor):
221 """Extract/pack signed 24-bit integers (-8388608 to 8388607)."""
223 __slots__ = ("_endian",)
225 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
226 """Initialize with endianness.
228 Args:
229 endian: Byte order, defaults to little-endian per BLE spec.
230 """
231 self._endian: Literal["little", "big"] = endian
233 @property
234 def byte_size(self) -> int:
235 """Size: 3 bytes."""
236 return 3
238 @property
239 def signed(self) -> bool:
240 """Signed type."""
241 return True
243 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
244 """Extract sint24 from bytes."""
245 return DataParser.parse_int24(data, offset, signed=True, endian=self._endian)
247 def pack(self, raw: int) -> bytearray:
248 """Pack sint24 to bytes."""
249 return DataParser.encode_int24(raw, signed=True, endian=self._endian)
252class Uint32Extractor(RawExtractor):
253 """Extract/pack unsigned 32-bit integers (0 to 4294967295)."""
255 __slots__ = ("_endian",)
257 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
258 """Initialize with endianness.
260 Args:
261 endian: Byte order, defaults to little-endian per BLE spec.
262 """
263 self._endian: Literal["little", "big"] = endian
265 @property
266 def byte_size(self) -> int:
267 """Size: 4 bytes."""
268 return 4
270 @property
271 def signed(self) -> bool:
272 """Unsigned type."""
273 return False
275 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
276 """Extract uint32 from bytes."""
277 return DataParser.parse_int32(data, offset, signed=False, endian=self._endian)
279 def pack(self, raw: int) -> bytearray:
280 """Pack uint32 to bytes."""
281 return DataParser.encode_int32(raw, signed=False, endian=self._endian)
284class Sint32Extractor(RawExtractor):
285 """Extract/pack signed 32-bit integers (-2147483648 to 2147483647)."""
287 __slots__ = ("_endian",)
289 def __init__(self, endian: Literal["little", "big"] = "little") -> None:
290 """Initialize with endianness.
292 Args:
293 endian: Byte order, defaults to little-endian per BLE spec.
294 """
295 self._endian: Literal["little", "big"] = endian
297 @property
298 def byte_size(self) -> int:
299 """Size: 4 bytes."""
300 return 4
302 @property
303 def signed(self) -> bool:
304 """Signed type."""
305 return True
307 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
308 """Extract sint32 from bytes."""
309 return DataParser.parse_int32(data, offset, signed=True, endian=self._endian)
311 def pack(self, raw: int) -> bytearray:
312 """Pack sint32 to bytes."""
313 return DataParser.encode_int32(raw, signed=True, endian=self._endian)
316class Float32Extractor(RawExtractor):
317 """Extract/pack IEEE-754 32-bit floats.
319 Unlike integer extractors, this returns the raw bits as an integer
320 to enable special value detection (NaN patterns, etc.) before
321 translation to float.
322 """
324 __slots__ = ()
326 @property
327 def byte_size(self) -> int:
328 """Size: 4 bytes."""
329 return 4
331 @property
332 def signed(self) -> bool:
333 """Floats are inherently signed."""
334 return True
336 def extract(self, data: bytes | bytearray, offset: int = 0) -> int:
337 """Extract float32 as raw bits for special value checking.
339 Returns the raw 32-bit integer representation of the float,
340 which allows special value detection (NaN patterns, etc.).
341 """
342 raw_bytes = data[offset : offset + 4]
343 return int.from_bytes(raw_bytes, byteorder="little", signed=False)
345 def pack(self, raw: int) -> bytearray:
346 """Pack raw bits to float32 bytes."""
347 return bytearray(raw.to_bytes(4, byteorder="little", signed=False))
349 def extract_float(self, data: bytes | bytearray, offset: int = 0) -> float:
350 """Extract as actual float value (convenience method)."""
351 return DataParser.parse_float32(bytearray(data), offset)
353 def pack_float(self, value: float) -> bytearray:
354 """Pack float value to bytes (convenience method)."""
355 return DataParser.encode_float32(value)
358# Singleton instances for common extractors (immutable, thread-safe)
359UINT8 = Uint8Extractor()
360SINT8 = Sint8Extractor()
361UINT16 = Uint16Extractor()
362SINT16 = Sint16Extractor()
363UINT24 = Uint24Extractor()
364SINT24 = Sint24Extractor()
365UINT32 = Uint32Extractor()
366SINT32 = Sint32Extractor()
367FLOAT32 = Float32Extractor()
369# Mapping from GSS type strings to extractor instances
370_EXTRACTOR_MAP: dict[str, RawExtractor] = {
371 "uint8": UINT8,
372 "sint8": SINT8,
373 "uint16": UINT16,
374 "sint16": SINT16,
375 "uint24": UINT24,
376 "sint24": SINT24,
377 "uint32": UINT32,
378 "sint32": SINT32,
379 "float32": FLOAT32,
380 "int16": SINT16,
381}
384def get_extractor(type_name: str) -> RawExtractor | None:
385 """Get extractor for a GSS type string.
387 Args:
388 type_name: Type string from GSS FieldSpec.type (e.g., "sint16", "uint8").
390 Returns:
391 Matching RawExtractor singleton, or None if type is not recognized.
393 Examples:
394 >>> extractor = get_extractor("sint16")
395 >>> raw = extractor.extract(data, offset=0)
396 """
397 return _EXTRACTOR_MAP.get(type_name.lower())