Coverage for src / bluetooth_sig / gatt / characteristics / templates / scaled.py: 85%
146 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +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 The GSS representation formula is: value = raw * M * 10^d * 2^b
125 Args:
126 M: Multiplier factor
127 d: Decimal exponent (10^d)
128 b: Binary exponent (2^b)
130 Returns:
131 ScaledTemplate instance
133 """
134 scale_factor = M * (10**d) * (2**b)
135 return cls(scale_factor=scale_factor, offset=0)
138class ScaledUint16Template(ScaledTemplate):
139 """Template for scaled 16-bit unsigned integer.
141 Used for values that need decimal precision encoded as integers.
142 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
143 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
144 Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
145 """
147 _extractor = UINT16
149 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
150 """Initialize with scale factor and offset.
152 Args:
153 scale_factor: Factor to multiply raw value by
154 offset: Offset to add to raw value before scaling
156 """
157 super().__init__(scale_factor, offset)
159 @property
160 def data_size(self) -> int:
161 """Size: 2 bytes."""
162 return 2
164 def _check_range(self, raw: int) -> None:
165 """Check range for uint16."""
166 if not 0 <= raw <= UINT16_MAX:
167 raise ValueError(f"Scaled value {raw} out of range for uint16")
170class ScaledSint16Template(ScaledTemplate):
171 """Template for scaled 16-bit signed integer.
173 Used for signed values that need decimal precision encoded as integers.
174 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
175 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
176 Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
177 """
179 _extractor = SINT16
181 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
182 """Initialize with scale factor and offset.
184 Args:
185 scale_factor: Factor to multiply raw value by
186 offset: Offset to add to raw value before scaling
188 """
189 super().__init__(scale_factor, offset)
191 @property
192 def data_size(self) -> int:
193 """Size: 2 bytes."""
194 return 2
196 def _check_range(self, raw: int) -> None:
197 """Check range for sint16."""
198 if not SINT16_MIN <= raw <= SINT16_MAX:
199 raise ValueError(f"Scaled value {raw} out of range for sint16")
202class ScaledSint8Template(ScaledTemplate):
203 """Template for scaled 8-bit signed integer.
205 Used for signed values that need decimal precision encoded as integers.
206 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
207 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
208 Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
209 """
211 _extractor = SINT8
213 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
214 """Initialize with scale factor and offset.
216 Args:
217 scale_factor: Factor to multiply raw value by
218 offset: Offset to add to raw value before scaling
220 """
221 super().__init__(scale_factor, offset)
223 @property
224 def data_size(self) -> int:
225 """Size: 1 byte."""
226 return 1
228 def _check_range(self, raw: int) -> None:
229 """Check range for sint8."""
230 if not SINT8_MIN <= raw <= SINT8_MAX:
231 raise ValueError(f"Scaled value {raw} out of range for sint8")
234class ScaledUint8Template(ScaledTemplate):
235 """Template for scaled 8-bit unsigned integer.
237 Used for unsigned values that need decimal precision encoded as integers.
238 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
239 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
240 Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0
241 """
243 _extractor = UINT8
245 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
246 """Initialize with scale factor and offset.
248 Args:
249 scale_factor: Factor to multiply raw value by
250 offset: Offset to add to raw value before scaling
252 """
253 super().__init__(scale_factor, offset)
255 @property
256 def data_size(self) -> int:
257 """Size: 1 byte."""
258 return 1
260 def _check_range(self, raw: int) -> None:
261 """Check range for uint8."""
262 if not 0 <= raw <= UINT8_MAX:
263 raise ValueError(f"Scaled value {raw} out of range for uint8")
266class ScaledUint32Template(ScaledTemplate):
267 """Template for scaled 32-bit unsigned integer with configurable resolution and offset."""
269 _extractor = UINT32
271 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None:
272 """Initialize with scale factor and offset.
274 Args:
275 scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place)
276 offset: Offset to add to raw value before scaling
278 """
279 super().__init__(scale_factor, offset)
281 @property
282 def data_size(self) -> int:
283 """Size: 4 bytes."""
284 return 4
286 def _check_range(self, raw: int) -> None:
287 """Check range for uint32."""
288 if not 0 <= raw <= UINT32_MAX:
289 raise ValueError(f"Scaled value {raw} out of range for uint32")
292class ScaledUint24Template(ScaledTemplate):
293 """Template for scaled 24-bit unsigned integer with configurable resolution and offset.
295 Used for values encoded in 3 bytes as unsigned integers.
296 Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0
297 """
299 _extractor = UINT24
301 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
302 """Initialize with scale factor and offset.
304 Args:
305 scale_factor: Factor to multiply raw value by
306 offset: Offset to add to raw value before scaling
308 """
309 super().__init__(scale_factor, offset)
311 @property
312 def data_size(self) -> int:
313 """Size: 3 bytes."""
314 return 3
316 def _check_range(self, raw: int) -> None:
317 """Check range for uint24."""
318 if not 0 <= raw <= UINT24_MAX:
319 raise ValueError(f"Scaled value {raw} out of range for uint24")
322class ScaledSint24Template(ScaledTemplate):
323 """Template for scaled 24-bit signed integer with configurable resolution and offset.
325 Used for signed values encoded in 3 bytes.
326 Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0
327 """
329 _extractor = SINT24
331 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
332 """Initialize with scale factor and offset.
334 Args:
335 scale_factor: Factor to multiply raw value by
336 offset: Offset to add to raw value before scaling
338 """
339 super().__init__(scale_factor, offset)
341 @property
342 def data_size(self) -> int:
343 """Size: 3 bytes."""
344 return 3
346 def _check_range(self, raw: int) -> None:
347 """Check range for sint24."""
348 if not SINT24_MIN <= raw <= SINT24_MAX:
349 raise ValueError(f"Scaled value {raw} out of range for sint24")
352class ScaledSint32Template(ScaledTemplate):
353 """Template for scaled 32-bit signed integer with configurable resolution and offset.
355 Used for signed values encoded in 4 bytes.
356 Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7
357 """
359 _extractor = SINT32
361 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
362 """Initialize with scale factor and offset.
364 Args:
365 scale_factor: Factor to multiply raw value by
366 offset: Offset to add to raw value before scaling
368 """
369 super().__init__(scale_factor, offset)
371 @property
372 def data_size(self) -> int:
373 """Size: 4 bytes."""
374 return 4
376 def _check_range(self, raw: int) -> None:
377 """Check range for sint32."""
378 sint32_min = -(2**31)
379 sint32_max = (2**31) - 1
380 if not sint32_min <= raw <= sint32_max:
381 raise ValueError(f"Scaled value {raw} out of range for sint32")
384class PercentageTemplate(CodingTemplate[int]):
385 """Template for percentage values (0-100%) using uint8."""
387 @property
388 def data_size(self) -> int:
389 """Size: 1 byte."""
390 return 1
392 @property
393 def extractor(self) -> RawExtractor:
394 """Get uint8 extractor."""
395 return UINT8
397 @property
398 def translator(self) -> IdentityTranslator:
399 """Return identity translator since validation is separate from translation."""
400 return IDENTITY
402 def decode_value(
403 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True
404 ) -> int:
405 """Parse percentage value."""
406 if validate and len(data) < offset + 1:
407 raise InsufficientDataError("percentage", data[offset:], 1)
408 value = self.extractor.extract(data, offset)
409 # Only validate range if validation is enabled
410 if validate and not 0 <= value <= PERCENTAGE_MAX:
411 raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX)
412 return self.translator.translate(value)
414 def encode_value(self, value: int, *, validate: bool = True) -> bytearray:
415 """Encode percentage value to bytes."""
416 if validate and not 0 <= value <= PERCENTAGE_MAX:
417 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})")
418 raw = self.translator.untranslate(value)
419 return self.extractor.pack(raw)