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

1"""Raw byte extractors for the BLE encoding/decoding pipeline. 

2 

3This module provides the extraction layer that ONLY converts bytes to raw integers 

4(and back). Extractors have a single responsibility: byte layout interpretation. 

5 

6The extraction layer is the first stage of the decode pipeline: 

7 bytes → [Extractor] → raw_int → [Translator] → typed_value 

8 

9Per Bluetooth SIG specifications, all multi-byte values use little-endian encoding 

10unless explicitly stated otherwise. 

11""" 

12 

13from __future__ import annotations 

14 

15from abc import ABC, abstractmethod 

16from typing import Literal 

17 

18from .data_parser import DataParser 

19 

20 

21class RawExtractor(ABC): 

22 """Protocol for raw byte extraction. 

23 

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. 

27 

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 """ 

33 

34 __slots__ = () 

35 

36 @property 

37 @abstractmethod 

38 def byte_size(self) -> int: 

39 """Number of bytes this extractor reads/writes.""" 

40 

41 @property 

42 @abstractmethod 

43 def signed(self) -> bool: 

44 """Whether the integer type is signed.""" 

45 

46 @abstractmethod 

47 def extract(self, data: bytes | bytearray, offset: int = 0) -> int: 

48 """Extract raw integer from bytes. 

49 

50 Args: 

51 data: Source bytes to extract from. 

52 offset: Byte offset to start reading from. 

53 

54 Returns: 

55 Raw integer value (not scaled or interpreted). 

56 

57 Raises: 

58 InsufficientDataError: If data is too short for extraction. 

59 """ 

60 

61 @abstractmethod 

62 def pack(self, raw: int) -> bytearray: 

63 """Pack raw integer to bytes. 

64 

65 Args: 

66 raw: Raw integer value to encode. 

67 

68 Returns: 

69 Packed bytes in little-endian format. 

70 

71 Raises: 

72 ValueRangeError: If raw value exceeds type bounds. 

73 """ 

74 

75 

76class Uint8Extractor(RawExtractor): 

77 """Extract/pack unsigned 8-bit integers (0 to 255).""" 

78 

79 __slots__ = () 

80 

81 @property 

82 def byte_size(self) -> int: 

83 """Size: 1 byte.""" 

84 return 1 

85 

86 @property 

87 def signed(self) -> bool: 

88 """Unsigned type.""" 

89 return False 

90 

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) 

94 

95 def pack(self, raw: int) -> bytearray: 

96 """Pack uint8 to bytes.""" 

97 return DataParser.encode_int8(raw, signed=False) 

98 

99 

100class Sint8Extractor(RawExtractor): 

101 """Extract/pack signed 8-bit integers (-128 to 127).""" 

102 

103 __slots__ = () 

104 

105 @property 

106 def byte_size(self) -> int: 

107 """Size: 1 byte.""" 

108 return 1 

109 

110 @property 

111 def signed(self) -> bool: 

112 """Signed type.""" 

113 return True 

114 

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) 

118 

119 def pack(self, raw: int) -> bytearray: 

120 """Pack sint8 to bytes.""" 

121 return DataParser.encode_int8(raw, signed=True) 

122 

123 

124class Uint16Extractor(RawExtractor): 

125 """Extract/pack unsigned 16-bit integers (0 to 65535).""" 

126 

127 __slots__ = ("_endian",) 

128 

129 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

130 """Initialize with endianness. 

131 

132 Args: 

133 endian: Byte order, defaults to little-endian per BLE spec. 

134 """ 

135 self._endian: Literal["little", "big"] = endian 

136 

137 @property 

138 def byte_size(self) -> int: 

139 """Size: 2 bytes.""" 

140 return 2 

141 

142 @property 

143 def signed(self) -> bool: 

144 """Unsigned type.""" 

145 return False 

146 

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) 

150 

151 def pack(self, raw: int) -> bytearray: 

152 """Pack uint16 to bytes.""" 

153 return DataParser.encode_int16(raw, signed=False, endian=self._endian) 

154 

155 

156class Sint16Extractor(RawExtractor): 

157 """Extract/pack signed 16-bit integers (-32768 to 32767).""" 

158 

159 __slots__ = ("_endian",) 

160 

161 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

162 """Initialize with endianness. 

163 

164 Args: 

165 endian: Byte order, defaults to little-endian per BLE spec. 

166 """ 

167 self._endian: Literal["little", "big"] = endian 

168 

169 @property 

170 def byte_size(self) -> int: 

171 """Size: 2 bytes.""" 

172 return 2 

173 

174 @property 

175 def signed(self) -> bool: 

176 """Signed type.""" 

177 return True 

178 

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) 

182 

183 def pack(self, raw: int) -> bytearray: 

184 """Pack sint16 to bytes.""" 

185 return DataParser.encode_int16(raw, signed=True, endian=self._endian) 

186 

187 

188class Uint24Extractor(RawExtractor): 

189 """Extract/pack unsigned 24-bit integers (0 to 16777215).""" 

190 

191 __slots__ = ("_endian",) 

192 

193 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

194 """Initialize with endianness. 

195 

196 Args: 

197 endian: Byte order, defaults to little-endian per BLE spec. 

198 """ 

199 self._endian: Literal["little", "big"] = endian 

200 

201 @property 

202 def byte_size(self) -> int: 

203 """Size: 3 bytes.""" 

204 return 3 

205 

206 @property 

207 def signed(self) -> bool: 

208 """Unsigned type.""" 

209 return False 

210 

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) 

214 

215 def pack(self, raw: int) -> bytearray: 

216 """Pack uint24 to bytes.""" 

217 return DataParser.encode_int24(raw, signed=False, endian=self._endian) 

218 

219 

220class Sint24Extractor(RawExtractor): 

221 """Extract/pack signed 24-bit integers (-8388608 to 8388607).""" 

222 

223 __slots__ = ("_endian",) 

224 

225 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

226 """Initialize with endianness. 

227 

228 Args: 

229 endian: Byte order, defaults to little-endian per BLE spec. 

230 """ 

231 self._endian: Literal["little", "big"] = endian 

232 

233 @property 

234 def byte_size(self) -> int: 

235 """Size: 3 bytes.""" 

236 return 3 

237 

238 @property 

239 def signed(self) -> bool: 

240 """Signed type.""" 

241 return True 

242 

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) 

246 

247 def pack(self, raw: int) -> bytearray: 

248 """Pack sint24 to bytes.""" 

249 return DataParser.encode_int24(raw, signed=True, endian=self._endian) 

250 

251 

252class Uint32Extractor(RawExtractor): 

253 """Extract/pack unsigned 32-bit integers (0 to 4294967295).""" 

254 

255 __slots__ = ("_endian",) 

256 

257 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

258 """Initialize with endianness. 

259 

260 Args: 

261 endian: Byte order, defaults to little-endian per BLE spec. 

262 """ 

263 self._endian: Literal["little", "big"] = endian 

264 

265 @property 

266 def byte_size(self) -> int: 

267 """Size: 4 bytes.""" 

268 return 4 

269 

270 @property 

271 def signed(self) -> bool: 

272 """Unsigned type.""" 

273 return False 

274 

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) 

278 

279 def pack(self, raw: int) -> bytearray: 

280 """Pack uint32 to bytes.""" 

281 return DataParser.encode_int32(raw, signed=False, endian=self._endian) 

282 

283 

284class Sint32Extractor(RawExtractor): 

285 """Extract/pack signed 32-bit integers (-2147483648 to 2147483647).""" 

286 

287 __slots__ = ("_endian",) 

288 

289 def __init__(self, endian: Literal["little", "big"] = "little") -> None: 

290 """Initialize with endianness. 

291 

292 Args: 

293 endian: Byte order, defaults to little-endian per BLE spec. 

294 """ 

295 self._endian: Literal["little", "big"] = endian 

296 

297 @property 

298 def byte_size(self) -> int: 

299 """Size: 4 bytes.""" 

300 return 4 

301 

302 @property 

303 def signed(self) -> bool: 

304 """Signed type.""" 

305 return True 

306 

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) 

310 

311 def pack(self, raw: int) -> bytearray: 

312 """Pack sint32 to bytes.""" 

313 return DataParser.encode_int32(raw, signed=True, endian=self._endian) 

314 

315 

316class Float32Extractor(RawExtractor): 

317 """Extract/pack IEEE-754 32-bit floats. 

318 

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 """ 

323 

324 __slots__ = () 

325 

326 @property 

327 def byte_size(self) -> int: 

328 """Size: 4 bytes.""" 

329 return 4 

330 

331 @property 

332 def signed(self) -> bool: 

333 """Floats are inherently signed.""" 

334 return True 

335 

336 def extract(self, data: bytes | bytearray, offset: int = 0) -> int: 

337 """Extract float32 as raw bits for special value checking. 

338 

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) 

344 

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)) 

348 

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) 

352 

353 def pack_float(self, value: float) -> bytearray: 

354 """Pack float value to bytes (convenience method).""" 

355 return DataParser.encode_float32(value) 

356 

357 

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() 

368 

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} 

382 

383 

384def get_extractor(type_name: str) -> RawExtractor | None: 

385 """Get extractor for a GSS type string. 

386 

387 Args: 

388 type_name: Type string from GSS FieldSpec.type (e.g., "sint16", "uint8"). 

389 

390 Returns: 

391 Matching RawExtractor singleton, or None if type is not recognized. 

392 

393 Examples: 

394 >>> extractor = get_extractor("sint16") 

395 >>> raw = extractor.extract(data, offset=0) 

396 """ 

397 return _EXTRACTOR_MAP.get(type_name.lower())