Coverage for src / bluetooth_sig / gatt / characteristics / templates / scaled.py: 85%
146 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Scaled value templates with configurable resolution and offset.
3Covers ScaledTemplate (abstract), ScaledUint8/16/24/32, ScaledSint8/16/24/32,
4and PercentageTemplate.
5"""
7from __future__ import annotations
9from abc import abstractmethod
11from ...constants import (
12 PERCENTAGE_MAX,
13 SINT8_MAX,
14 SINT8_MIN,
15 SINT16_MAX,
16 SINT16_MIN,
17 SINT24_MAX,
18 SINT24_MIN,
19 UINT8_MAX,
20 UINT16_MAX,
21 UINT24_MAX,
22 UINT32_MAX,
23)
24from ...context import CharacteristicContext
25from ...exceptions import InsufficientDataError, ValueRangeError
26from ..utils.extractors import (
27 SINT8,
28 SINT16,
29 SINT24,
30 SINT32,
31 UINT8,
32 UINT16,
33 UINT24,
34 UINT32,
35 RawExtractor,
36)
37from ..utils.translators import (
38 IDENTITY,
39 IdentityTranslator,
40 LinearTranslator,
41)
42from .base import CodingTemplate
45class ScaledTemplate(CodingTemplate[float]):
46 """Base class for scaled integer templates.
48 Handles common scaling logic: value = (raw + offset) * scale_factor
49 Subclasses implement raw parsing/encoding and range checking.
51 Exposes `extractor` and `translator` for pipeline access.
52 """
54 _extractor: RawExtractor
55 _translator: LinearTranslator
57 def __init__(self, scale_factor: float, offset: int) -> None:
58 """Initialize with scale factor and offset.
60 Args:
61 scale_factor: Factor to multiply raw value by
62 offset: Offset to add to raw value before scaling
64 """
65 self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset)
67 @property
68 def scale_factor(self) -> float:
69 """Get the scale factor."""
70 return self._translator.scale_factor
72 @property
73 def offset(self) -> int:
74 """Get the offset."""
75 return self._translator.offset
77 @property
78 def extractor(self) -> RawExtractor:
79 """Get the byte extractor for pipeline access."""
80 return self._extractor
82 @property
83 def translator(self) -> LinearTranslator:
84 """Get the value translator for pipeline access."""
85 return self._translator
87 def decode_value(
88 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True
89 ) -> float:
90 """Parse scaled integer value."""
91 raw_value = self._extractor.extract(data, offset)
92 return self._translator.translate(raw_value)
94 def encode_value(self, value: float, *, validate: bool = True) -> bytearray:
95 """Encode scaled value to bytes."""
96 raw_value = self._translator.untranslate(value)
97 if validate:
98 self._check_range(raw_value)
99 return self._extractor.pack(raw_value)
101 @abstractmethod
102 def _check_range(self, raw: int) -> None:
103 """Check if raw value is in valid range."""
105 @classmethod
106 def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate:
107 """Create instance using scale factor and offset.
109 Args:
110 scale_factor: Factor to multiply raw value by
111 offset: Offset to add to raw value before scaling
113 Returns:
114 ScaledTemplate instance
116 """
117 return cls(scale_factor=scale_factor, offset=offset)
119 @classmethod
120 def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate: # noqa: N803
121 """Create instance using Bluetooth SIG M, d, b parameters.
123 Args:
124 M: Multiplier factor
125 d: Decimal exponent (10^d)
126 b: Offset to add to raw value before scaling
128 Returns:
129 ScaledTemplate instance
131 """
132 scale_factor = M * (10**d)
133 return cls(scale_factor=scale_factor, offset=b)
136class ScaledUint16Template(ScaledTemplate):
137 """Template for scaled 16-bit unsigned integer.
139 Used for values that need decimal precision encoded as integers.
140 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
141 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
142 Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
143 """
145 _extractor = UINT16
147 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
148 """Initialize with scale factor and offset.
150 Args:
151 scale_factor: Factor to multiply raw value by
152 offset: Offset to add to raw value before scaling
154 """
155 super().__init__(scale_factor, offset)
157 @property
158 def data_size(self) -> int:
159 """Size: 2 bytes."""
160 return 2
162 def _check_range(self, raw: int) -> None:
163 """Check range for uint16."""
164 if not 0 <= raw <= UINT16_MAX:
165 raise ValueError(f"Scaled value {raw} out of range for uint16")
168class ScaledSint16Template(ScaledTemplate):
169 """Template for scaled 16-bit signed integer.
171 Used for signed values that need decimal precision encoded as integers.
172 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
173 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
174 Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
175 """
177 _extractor = SINT16
179 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
180 """Initialize with scale factor and offset.
182 Args:
183 scale_factor: Factor to multiply raw value by
184 offset: Offset to add to raw value before scaling
186 """
187 super().__init__(scale_factor, offset)
189 @property
190 def data_size(self) -> int:
191 """Size: 2 bytes."""
192 return 2
194 def _check_range(self, raw: int) -> None:
195 """Check range for sint16."""
196 if not SINT16_MIN <= raw <= SINT16_MAX:
197 raise ValueError(f"Scaled value {raw} out of range for sint16")
200class ScaledSint8Template(ScaledTemplate):
201 """Template for scaled 8-bit signed integer.
203 Used for signed values that need decimal precision encoded as integers.
204 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
205 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
206 Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
207 """
209 _extractor = SINT8
211 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
212 """Initialize with scale factor and offset.
214 Args:
215 scale_factor: Factor to multiply raw value by
216 offset: Offset to add to raw value before scaling
218 """
219 super().__init__(scale_factor, offset)
221 @property
222 def data_size(self) -> int:
223 """Size: 1 byte."""
224 return 1
226 def _check_range(self, raw: int) -> None:
227 """Check range for sint8."""
228 if not SINT8_MIN <= raw <= SINT8_MAX:
229 raise ValueError(f"Scaled value {raw} out of range for sint8")
232class ScaledUint8Template(ScaledTemplate):
233 """Template for scaled 8-bit unsigned integer.
235 Used for unsigned values that need decimal precision encoded as integers.
236 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
237 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
238 Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0
239 """
241 _extractor = UINT8
243 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
244 """Initialize with scale factor and offset.
246 Args:
247 scale_factor: Factor to multiply raw value by
248 offset: Offset to add to raw value before scaling
250 """
251 super().__init__(scale_factor, offset)
253 @property
254 def data_size(self) -> int:
255 """Size: 1 byte."""
256 return 1
258 def _check_range(self, raw: int) -> None:
259 """Check range for uint8."""
260 if not 0 <= raw <= UINT8_MAX:
261 raise ValueError(f"Scaled value {raw} out of range for uint8")
264class ScaledUint32Template(ScaledTemplate):
265 """Template for scaled 32-bit unsigned integer with configurable resolution and offset."""
267 _extractor = UINT32
269 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None:
270 """Initialize with scale factor and offset.
272 Args:
273 scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place)
274 offset: Offset to add to raw value before scaling
276 """
277 super().__init__(scale_factor, offset)
279 @property
280 def data_size(self) -> int:
281 """Size: 4 bytes."""
282 return 4
284 def _check_range(self, raw: int) -> None:
285 """Check range for uint32."""
286 if not 0 <= raw <= UINT32_MAX:
287 raise ValueError(f"Scaled value {raw} out of range for uint32")
290class ScaledUint24Template(ScaledTemplate):
291 """Template for scaled 24-bit unsigned integer with configurable resolution and offset.
293 Used for values encoded in 3 bytes as unsigned integers.
294 Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0
295 """
297 _extractor = UINT24
299 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
300 """Initialize with scale factor and offset.
302 Args:
303 scale_factor: Factor to multiply raw value by
304 offset: Offset to add to raw value before scaling
306 """
307 super().__init__(scale_factor, offset)
309 @property
310 def data_size(self) -> int:
311 """Size: 3 bytes."""
312 return 3
314 def _check_range(self, raw: int) -> None:
315 """Check range for uint24."""
316 if not 0 <= raw <= UINT24_MAX:
317 raise ValueError(f"Scaled value {raw} out of range for uint24")
320class ScaledSint24Template(ScaledTemplate):
321 """Template for scaled 24-bit signed integer with configurable resolution and offset.
323 Used for signed values encoded in 3 bytes.
324 Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0
325 """
327 _extractor = SINT24
329 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
330 """Initialize with scale factor and offset.
332 Args:
333 scale_factor: Factor to multiply raw value by
334 offset: Offset to add to raw value before scaling
336 """
337 super().__init__(scale_factor, offset)
339 @property
340 def data_size(self) -> int:
341 """Size: 3 bytes."""
342 return 3
344 def _check_range(self, raw: int) -> None:
345 """Check range for sint24."""
346 if not SINT24_MIN <= raw <= SINT24_MAX:
347 raise ValueError(f"Scaled value {raw} out of range for sint24")
350class ScaledSint32Template(ScaledTemplate):
351 """Template for scaled 32-bit signed integer with configurable resolution and offset.
353 Used for signed values encoded in 4 bytes.
354 Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7
355 """
357 _extractor = SINT32
359 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
360 """Initialize with scale factor and offset.
362 Args:
363 scale_factor: Factor to multiply raw value by
364 offset: Offset to add to raw value before scaling
366 """
367 super().__init__(scale_factor, offset)
369 @property
370 def data_size(self) -> int:
371 """Size: 4 bytes."""
372 return 4
374 def _check_range(self, raw: int) -> None:
375 """Check range for sint32."""
376 sint32_min = -(2**31)
377 sint32_max = (2**31) - 1
378 if not sint32_min <= raw <= sint32_max:
379 raise ValueError(f"Scaled value {raw} out of range for sint32")
382class PercentageTemplate(CodingTemplate[int]):
383 """Template for percentage values (0-100%) using uint8."""
385 @property
386 def data_size(self) -> int:
387 """Size: 1 byte."""
388 return 1
390 @property
391 def extractor(self) -> RawExtractor:
392 """Get uint8 extractor."""
393 return UINT8
395 @property
396 def translator(self) -> IdentityTranslator:
397 """Return identity translator since validation is separate from translation."""
398 return IDENTITY
400 def decode_value(
401 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True
402 ) -> int:
403 """Parse percentage value."""
404 if validate and len(data) < offset + 1:
405 raise InsufficientDataError("percentage", data[offset:], 1)
406 value = self.extractor.extract(data, offset)
407 # Only validate range if validation is enabled
408 if validate and not 0 <= value <= PERCENTAGE_MAX:
409 raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX)
410 return self.translator.translate(value)
412 def encode_value(self, value: int, *, validate: bool = True) -> bytearray:
413 """Encode percentage value to bytes."""
414 if validate and not 0 <= value <= PERCENTAGE_MAX:
415 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})")
416 raw = self.translator.untranslate(value)
417 return self.extractor.pack(raw)