Coverage for src / bluetooth_sig / gatt / characteristics / templates.py: 82%
558 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# mypy: warn_unused_ignores=False
2"""Coding templates for characteristic composition patterns.
4This module provides reusable coding template classes that can be composed into
5characteristics via dependency injection. Templates are pure coding strategies
6that do NOT inherit from BaseCharacteristic.
8All templates follow the CodingTemplate protocol and can be used by both SIG
9and custom characteristics through composition.
11Pipeline architecture:
12 bytes → [Extractor] → raw_int → [Translator] → typed_value
14Templates that handle single-field data expose `extractor` and `translator`
15properties for pipeline access. Complex templates (multi-field, variable-length)
16keep monolithic decode/encode since there's no single raw value to intercept.
17"""
18# pylint: disable=too-many-lines # Template classes are cohesive - splitting would break composition pattern
20from __future__ import annotations
22from abc import ABC, abstractmethod
23from datetime import datetime
24from enum import IntEnum
25from typing import Any, Generic, TypeVar
27import msgspec
29from ...types.gatt_enums import AdjustReason, DayOfWeek
30from ..constants import (
31 PERCENTAGE_MAX,
32 SINT8_MAX,
33 SINT8_MIN,
34 SINT16_MAX,
35 SINT16_MIN,
36 SINT24_MAX,
37 SINT24_MIN,
38 UINT8_MAX,
39 UINT16_MAX,
40 UINT24_MAX,
41 UINT32_MAX,
42)
43from ..context import CharacteristicContext
44from ..exceptions import InsufficientDataError, ValueRangeError
45from .utils import DataParser, IEEE11073Parser
46from .utils.extractors import (
47 FLOAT32,
48 SINT8,
49 SINT16,
50 SINT24,
51 SINT32,
52 UINT8,
53 UINT16,
54 UINT24,
55 UINT32,
56 RawExtractor,
57)
58from .utils.translators import (
59 IDENTITY,
60 SFLOAT,
61 IdentityTranslator,
62 LinearTranslator,
63 SfloatTranslator,
64 ValueTranslator,
65)
67# =============================================================================
68# TYPE VARIABLES
69# =============================================================================
71# Type variable for CodingTemplate generic - represents the decoded value type
72T_co = TypeVar("T_co", covariant=True)
74# Type variable for EnumTemplate - bound to IntEnum
75T = TypeVar("T", bound=IntEnum)
78# =============================================================================
79# LEVEL 4 BASE CLASS
80# =============================================================================
83class CodingTemplate(ABC, Generic[T_co]):
84 """Abstract base class for coding templates.
86 Templates are pure coding utilities that don't inherit from BaseCharacteristic.
87 They provide coding strategies that can be injected into characteristics.
88 All templates MUST inherit from this base class and implement the required methods.
90 Generic over T_co, the type of value produced by _decode_value.
91 Concrete templates specify their return type, e.g., CodingTemplate[int].
93 Pipeline Integration:
94 Simple templates (single-field) expose `extractor` and `translator` properties
95 for the decode/encode pipeline. Complex templates return None for these properties.
96 """
98 @abstractmethod
99 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> T_co:
100 """Decode raw bytes to typed value.
102 Args:
103 data: Raw bytes to parse
104 offset: Byte offset to start parsing from
105 ctx: Optional context for parsing
107 Returns:
108 Parsed value of type T_co
110 """
112 @abstractmethod
113 def encode_value(self, value: T_co) -> bytearray: # type: ignore[misc] # Covariant type in parameter is intentional for encode/decode symmetry
114 """Encode typed value to raw bytes.
116 Args:
117 value: Typed value to encode
119 Returns:
120 Raw bytes representing the value
122 """
124 @property
125 @abstractmethod
126 def data_size(self) -> int:
127 """Size of data in bytes that this template handles."""
129 @property
130 def extractor(self) -> RawExtractor | None:
131 """Get the raw byte extractor for pipeline access.
133 Returns None for complex templates where extraction isn't separable.
134 """
135 return None
137 @property
138 def translator(self) -> ValueTranslator[Any] | None:
139 """Get the value translator for pipeline access.
141 Returns None for complex templates where translation isn't separable.
142 """
143 return None
146# =============================================================================
147# DATA STRUCTURES
148# =============================================================================
151class VectorData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
152 """3D vector measurement data."""
154 x_axis: float
155 y_axis: float
156 z_axis: float
159class Vector2DData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
160 """2D vector measurement data."""
162 x_axis: float
163 y_axis: float
166class TimeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
167 """Time characteristic data structure."""
169 date_time: datetime | None
170 day_of_week: DayOfWeek
171 fractions256: int
172 adjust_reason: AdjustReason
175# =============================================================================
176# BASIC INTEGER TEMPLATES
177# =============================================================================
180class Uint8Template(CodingTemplate[int]):
181 """Template for 8-bit unsigned integer parsing (0-255)."""
183 @property
184 def data_size(self) -> int:
185 """Size: 1 byte."""
186 return 1
188 @property
189 def extractor(self) -> RawExtractor:
190 """Get uint8 extractor."""
191 return UINT8
193 @property
194 def translator(self) -> ValueTranslator[int]:
195 """Return identity translator for no scaling."""
196 return IDENTITY
198 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
199 """Parse 8-bit unsigned integer."""
200 if len(data) < offset + 1:
201 raise InsufficientDataError("uint8", data[offset:], 1)
202 return self.extractor.extract(data, offset)
204 def encode_value(self, value: int) -> bytearray:
205 """Encode uint8 value to bytes."""
206 if not 0 <= value <= UINT8_MAX:
207 raise ValueError(f"Value {value} out of range for uint8 (0-{UINT8_MAX})")
208 return self.extractor.pack(value)
211class Sint8Template(CodingTemplate[int]):
212 """Template for 8-bit signed integer parsing (-128 to 127)."""
214 @property
215 def data_size(self) -> int:
216 """Size: 1 byte."""
217 return 1
219 @property
220 def extractor(self) -> RawExtractor:
221 """Get sint8 extractor."""
222 return SINT8
224 @property
225 def translator(self) -> ValueTranslator[int]:
226 """Return identity translator for no scaling."""
227 return IDENTITY
229 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
230 """Parse 8-bit signed integer."""
231 if len(data) < offset + 1:
232 raise InsufficientDataError("sint8", data[offset:], 1)
233 return self.extractor.extract(data, offset)
235 def encode_value(self, value: int) -> bytearray:
236 """Encode sint8 value to bytes."""
237 if not SINT8_MIN <= value <= SINT8_MAX:
238 raise ValueError(f"Value {value} out of range for sint8 ({SINT8_MIN} to {SINT8_MAX})")
239 return self.extractor.pack(value)
242class Uint16Template(CodingTemplate[int]):
243 """Template for 16-bit unsigned integer parsing (0-65535)."""
245 @property
246 def data_size(self) -> int:
247 """Size: 2 bytes."""
248 return 2
250 @property
251 def extractor(self) -> RawExtractor:
252 """Get uint16 extractor."""
253 return UINT16
255 @property
256 def translator(self) -> ValueTranslator[int]:
257 """Return identity translator for no scaling."""
258 return IDENTITY
260 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
261 """Parse 16-bit unsigned integer."""
262 if len(data) < offset + 2:
263 raise InsufficientDataError("uint16", data[offset:], 2)
264 return self.extractor.extract(data, offset)
266 def encode_value(self, value: int) -> bytearray:
267 """Encode uint16 value to bytes."""
268 if not 0 <= value <= UINT16_MAX:
269 raise ValueError(f"Value {value} out of range for uint16 (0-{UINT16_MAX})")
270 return self.extractor.pack(value)
273class Sint16Template(CodingTemplate[int]):
274 """Template for 16-bit signed integer parsing (-32768 to 32767)."""
276 @property
277 def data_size(self) -> int:
278 """Size: 2 bytes."""
279 return 2
281 @property
282 def extractor(self) -> RawExtractor:
283 """Get sint16 extractor."""
284 return SINT16
286 @property
287 def translator(self) -> ValueTranslator[int]:
288 """Return identity translator for no scaling."""
289 return IDENTITY
291 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
292 """Parse 16-bit signed integer."""
293 if len(data) < offset + 2:
294 raise InsufficientDataError("sint16", data[offset:], 2)
295 return self.extractor.extract(data, offset)
297 def encode_value(self, value: int) -> bytearray:
298 """Encode sint16 value to bytes."""
299 if not SINT16_MIN <= value <= SINT16_MAX:
300 raise ValueError(f"Value {value} out of range for sint16 ({SINT16_MIN} to {SINT16_MAX})")
301 return self.extractor.pack(value)
304class Uint24Template(CodingTemplate[int]):
305 """Template for 24-bit unsigned integer parsing (0-16777215)."""
307 @property
308 def data_size(self) -> int:
309 """Size: 3 bytes."""
310 return 3
312 @property
313 def extractor(self) -> RawExtractor:
314 """Get uint24 extractor."""
315 return UINT24
317 @property
318 def translator(self) -> ValueTranslator[int]:
319 """Return identity translator for no scaling."""
320 return IDENTITY
322 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
323 """Parse 24-bit unsigned integer."""
324 if len(data) < offset + 3:
325 raise InsufficientDataError("uint24", data[offset:], 3)
326 return self.extractor.extract(data, offset)
328 def encode_value(self, value: int) -> bytearray:
329 """Encode uint24 value to bytes."""
330 if not 0 <= value <= UINT24_MAX:
331 raise ValueError(f"Value {value} out of range for uint24 (0-{UINT24_MAX})")
332 return self.extractor.pack(value)
335class Uint32Template(CodingTemplate[int]):
336 """Template for 32-bit unsigned integer parsing."""
338 @property
339 def data_size(self) -> int:
340 """Size: 4 bytes."""
341 return 4
343 @property
344 def extractor(self) -> RawExtractor:
345 """Get uint32 extractor."""
346 return UINT32
348 @property
349 def translator(self) -> ValueTranslator[int]:
350 """Return identity translator for no scaling."""
351 return IDENTITY
353 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
354 """Parse 32-bit unsigned integer."""
355 if len(data) < offset + 4:
356 raise InsufficientDataError("uint32", data[offset:], 4)
357 return self.extractor.extract(data, offset)
359 def encode_value(self, value: int) -> bytearray:
360 """Encode uint32 value to bytes."""
361 if not 0 <= value <= UINT32_MAX:
362 raise ValueError(f"Value {value} out of range for uint32 (0-{UINT32_MAX})")
363 return self.extractor.pack(value)
366class EnumTemplate(CodingTemplate[T]):
367 """Template for IntEnum encoding/decoding with configurable byte size.
369 Maps raw integer bytes to Python IntEnum instances through extraction and validation.
370 Supports any integer-based enum with any extractor (UINT8, UINT16, SINT8, etc.).
372 This template validates enum membership explicitly, supporting non-contiguous
373 enum ranges (e.g., values 0, 2, 5, 10).
375 Pipeline Integration:
376 bytes → [extractor] → raw_int → [IDENTITY translator] → int → enum constructor
378 Examples:
379 >>> class Status(IntEnum):
380 ... IDLE = 0
381 ... ACTIVE = 1
382 ... ERROR = 2
383 >>>
384 >>> # Create template with factory method
385 >>> template = EnumTemplate.uint8(Status)
386 >>>
387 >>> # Or with explicit extractor
388 >>> template = EnumTemplate(Status, UINT8)
389 >>>
390 >>> # Decode from bytes
391 >>> status = template.decode_value(bytearray([0x01])) # Status.ACTIVE
392 >>>
393 >>> # Encode enum to bytes
394 >>> data = template.encode_value(Status.ERROR) # bytearray([0x02])
395 >>>
396 >>> # Encode int to bytes (also supported)
397 >>> data = template.encode_value(2) # bytearray([0x02])
398 """
400 def __init__(self, enum_class: type[T], extractor: RawExtractor) -> None:
401 """Initialize with enum class and extractor.
403 Args:
404 enum_class: IntEnum subclass to encode/decode
405 extractor: Raw extractor defining byte size and signedness
406 (e.g., UINT8, UINT16, SINT8, etc.)
407 """
408 self._enum_class = enum_class
409 self._extractor = extractor
411 @property
412 def data_size(self) -> int:
413 """Return byte size required for encoding."""
414 return self._extractor.byte_size
416 @property
417 def extractor(self) -> RawExtractor:
418 """Return extractor for pipeline access."""
419 return self._extractor
421 @property
422 def translator(self) -> ValueTranslator[int]:
423 """Get IDENTITY translator for enums (no scaling needed)."""
424 return IDENTITY
426 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> T:
427 """Decode bytes to enum instance.
429 Args:
430 data: Raw bytes from BLE characteristic
431 offset: Starting offset in data buffer
432 ctx: Optional context for parsing
434 Returns:
435 Enum instance of type T
437 Raises:
438 InsufficientDataError: If data too short for required byte size
439 ValueRangeError: If raw value not a valid enum member
440 """
441 # Check data length
442 if len(data) < offset + self.data_size:
443 raise InsufficientDataError(self._enum_class.__name__, data[offset:], self.data_size)
445 # Extract raw integer value
446 raw_value = self._extractor.extract(data, offset)
448 # Validate enum membership and construct
449 try:
450 return self._enum_class(raw_value)
451 except ValueError as e:
452 # Get valid range from enum members
453 valid_values = [member.value for member in self._enum_class]
454 min_val = min(valid_values)
455 max_val = max(valid_values)
456 raise ValueRangeError(self._enum_class.__name__, raw_value, min_val, max_val) from e
458 def encode_value(self, value: T | int) -> bytearray:
459 """Encode enum instance or int to bytes.
461 Args:
462 value: Enum instance or integer value to encode
464 Returns:
465 Encoded bytes
467 Raises:
468 ValueError: If value not a valid enum member
469 """
470 # Convert to int if enum instance
471 int_value = value.value if isinstance(value, self._enum_class) else int(value)
473 # Validate membership
474 valid_values = [member.value for member in self._enum_class]
475 if int_value not in valid_values:
476 min_val = min(valid_values)
477 max_val = max(valid_values)
478 raise ValueError(
479 f"{self._enum_class.__name__} value {int_value} is invalid. "
480 f"Valid range: {min_val}-{max_val}, valid values: {sorted(valid_values)}"
481 )
483 # Pack to bytes
484 return self._extractor.pack(int_value)
486 @classmethod
487 def uint8(cls, enum_class: type[T]) -> EnumTemplate[T]:
488 """Create EnumTemplate for 1-byte unsigned enum.
490 Args:
491 enum_class: IntEnum subclass with values 0-255
493 Returns:
494 Configured EnumTemplate instance
496 Example:
497 >>> class Status(IntEnum):
498 ... IDLE = 0
499 ... ACTIVE = 1
500 >>> template = EnumTemplate.uint8(Status)
501 """
502 return cls(enum_class, UINT8)
504 @classmethod
505 def uint16(cls, enum_class: type[T]) -> EnumTemplate[T]:
506 """Create EnumTemplate for 2-byte unsigned enum.
508 Args:
509 enum_class: IntEnum subclass with values 0-65535
511 Returns:
512 Configured EnumTemplate instance
514 Example:
515 >>> class ExtendedStatus(IntEnum):
516 ... STATE_1 = 0x0100
517 ... STATE_2 = 0x0200
518 >>> template = EnumTemplate.uint16(ExtendedStatus)
519 """
520 return cls(enum_class, UINT16)
522 @classmethod
523 def uint32(cls, enum_class: type[T]) -> EnumTemplate[T]:
524 """Create EnumTemplate for 4-byte unsigned enum.
526 Args:
527 enum_class: IntEnum subclass with values 0-4294967295
529 Returns:
530 Configured EnumTemplate instance
531 """
532 return cls(enum_class, UINT32)
534 @classmethod
535 def sint8(cls, enum_class: type[T]) -> EnumTemplate[T]:
536 """Create EnumTemplate for 1-byte signed enum.
538 Args:
539 enum_class: IntEnum subclass with values -128 to 127
541 Returns:
542 Configured EnumTemplate instance
544 Example:
545 >>> class Temperature(IntEnum):
546 ... FREEZING = -10
547 ... NORMAL = 0
548 ... HOT = 10
549 >>> template = EnumTemplate.sint8(Temperature)
550 """
551 return cls(enum_class, SINT8)
553 @classmethod
554 def sint16(cls, enum_class: type[T]) -> EnumTemplate[T]:
555 """Create EnumTemplate for 2-byte signed enum.
557 Args:
558 enum_class: IntEnum subclass with values -32768 to 32767
560 Returns:
561 Configured EnumTemplate instance
562 """
563 return cls(enum_class, SINT16)
565 @classmethod
566 def sint32(cls, enum_class: type[T]) -> EnumTemplate[T]:
567 """Create EnumTemplate for 4-byte signed enum.
569 Args:
570 enum_class: IntEnum subclass with values -2147483648 to 2147483647
572 Returns:
573 Configured EnumTemplate instance
574 """
575 return cls(enum_class, SINT32)
578# =============================================================================
579# SCALED VALUE TEMPLATES
580# =============================================================================
583class ScaledTemplate(CodingTemplate[float]):
584 """Base class for scaled integer templates.
586 Handles common scaling logic: value = (raw + offset) * scale_factor
587 Subclasses implement raw parsing/encoding and range checking.
589 Exposes `extractor` and `translator` for pipeline access.
590 """
592 _extractor: RawExtractor
593 _translator: LinearTranslator
595 def __init__(self, scale_factor: float, offset: int) -> None:
596 """Initialize with scale factor and offset.
598 Args:
599 scale_factor: Factor to multiply raw value by
600 offset: Offset to add to raw value before scaling
602 """
603 self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset)
605 @property
606 def scale_factor(self) -> float:
607 """Get the scale factor."""
608 return self._translator.scale_factor
610 @property
611 def offset(self) -> int:
612 """Get the offset."""
613 return self._translator.offset
615 @property
616 def extractor(self) -> RawExtractor:
617 """Get the byte extractor for pipeline access."""
618 return self._extractor
620 @property
621 def translator(self) -> LinearTranslator:
622 """Get the value translator for pipeline access."""
623 return self._translator
625 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
626 """Parse scaled integer value."""
627 raw_value = self._extractor.extract(data, offset)
628 return self._translator.translate(raw_value)
630 def encode_value(self, value: float) -> bytearray:
631 """Encode scaled value to bytes."""
632 raw_value = self._translator.untranslate(value)
633 self._check_range(raw_value)
634 return self._extractor.pack(raw_value)
636 @abstractmethod
637 def _check_range(self, raw: int) -> None:
638 """Check if raw value is in valid range."""
640 @classmethod
641 def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate:
642 """Create instance using scale factor and offset.
644 Args:
645 scale_factor: Factor to multiply raw value by
646 offset: Offset to add to raw value before scaling
648 Returns:
649 ScaledTemplate instance
651 """
652 return cls(scale_factor=scale_factor, offset=offset)
654 @classmethod
655 def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate:
656 """Create instance using Bluetooth SIG M, d, b parameters.
658 Args:
659 M: Multiplier factor
660 d: Decimal exponent (10^d)
661 b: Offset to add to raw value before scaling
663 Returns:
664 ScaledTemplate instance
666 """
667 scale_factor = M * (10**d)
668 return cls(scale_factor=scale_factor, offset=b)
671class ScaledUint16Template(ScaledTemplate):
672 """Template for scaled 16-bit unsigned integer.
674 Used for values that need decimal precision encoded as integers.
675 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
676 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
677 Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
678 """
680 _extractor = UINT16
682 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
683 """Initialize with scale factor and offset.
685 Args:
686 scale_factor: Factor to multiply raw value by
687 offset: Offset to add to raw value before scaling
689 """
690 super().__init__(scale_factor, offset)
692 @property
693 def data_size(self) -> int:
694 """Size: 2 bytes."""
695 return 2
697 def _check_range(self, raw: int) -> None:
698 """Check range for uint16."""
699 if not 0 <= raw <= UINT16_MAX:
700 raise ValueError(f"Scaled value {raw} out of range for uint16")
703class ScaledSint16Template(ScaledTemplate):
704 """Template for scaled 16-bit signed integer.
706 Used for signed values that need decimal precision encoded as integers.
707 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
708 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
709 Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
710 """
712 _extractor = SINT16
714 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
715 """Initialize with scale factor and offset.
717 Args:
718 scale_factor: Factor to multiply raw value by
719 offset: Offset to add to raw value before scaling
721 """
722 super().__init__(scale_factor, offset)
724 @property
725 def data_size(self) -> int:
726 """Size: 2 bytes."""
727 return 2
729 def _check_range(self, raw: int) -> None:
730 """Check range for sint16."""
731 if not SINT16_MIN <= raw <= SINT16_MAX:
732 raise ValueError(f"Scaled value {raw} out of range for sint16")
735class ScaledSint8Template(ScaledTemplate):
736 """Template for scaled 8-bit signed integer.
738 Used for signed values that need decimal precision encoded as integers.
739 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
740 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
741 Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
742 """
744 _extractor = SINT8
746 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
747 """Initialize with scale factor and offset.
749 Args:
750 scale_factor: Factor to multiply raw value by
751 offset: Offset to add to raw value before scaling
753 """
754 super().__init__(scale_factor, offset)
756 @property
757 def data_size(self) -> int:
758 """Size: 1 byte."""
759 return 1
761 def _check_range(self, raw: int) -> None:
762 """Check range for sint8."""
763 if not SINT8_MIN <= raw <= SINT8_MAX:
764 raise ValueError(f"Scaled value {raw} out of range for sint8")
767class ScaledUint8Template(ScaledTemplate):
768 """Template for scaled 8-bit unsigned integer.
770 Used for unsigned values that need decimal precision encoded as integers.
771 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
772 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
773 Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0
774 """
776 _extractor = UINT8
778 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
779 """Initialize with scale factor and offset.
781 Args:
782 scale_factor: Factor to multiply raw value by
783 offset: Offset to add to raw value before scaling
785 """
786 super().__init__(scale_factor, offset)
788 @property
789 def data_size(self) -> int:
790 """Size: 1 byte."""
791 return 1
793 def _check_range(self, raw: int) -> None:
794 """Check range for uint8."""
795 if not 0 <= raw <= UINT8_MAX:
796 raise ValueError(f"Scaled value {raw} out of range for uint8")
799class ScaledUint32Template(ScaledTemplate):
800 """Template for scaled 32-bit unsigned integer with configurable resolution and offset."""
802 _extractor = UINT32
804 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None:
805 """Initialize with scale factor and offset.
807 Args:
808 scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place)
809 offset: Offset to add to raw value before scaling
811 """
812 super().__init__(scale_factor, offset)
814 @property
815 def data_size(self) -> int:
816 """Size: 4 bytes."""
817 return 4
819 def _check_range(self, raw: int) -> None:
820 """Check range for uint32."""
821 if not 0 <= raw <= UINT32_MAX:
822 raise ValueError(f"Scaled value {raw} out of range for uint32")
825class ScaledUint24Template(ScaledTemplate):
826 """Template for scaled 24-bit unsigned integer with configurable resolution and offset.
828 Used for values encoded in 3 bytes as unsigned integers.
829 Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0
830 """
832 _extractor = UINT24
834 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
835 """Initialize with scale factor and offset.
837 Args:
838 scale_factor: Factor to multiply raw value by
839 offset: Offset to add to raw value before scaling
841 """
842 super().__init__(scale_factor, offset)
844 @property
845 def data_size(self) -> int:
846 """Size: 3 bytes."""
847 return 3
849 def _check_range(self, raw: int) -> None:
850 """Check range for uint24."""
851 if not 0 <= raw <= UINT24_MAX:
852 raise ValueError(f"Scaled value {raw} out of range for uint24")
855class ScaledSint24Template(ScaledTemplate):
856 """Template for scaled 24-bit signed integer with configurable resolution and offset.
858 Used for signed values encoded in 3 bytes.
859 Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0
860 """
862 _extractor = SINT24
864 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
865 """Initialize with scale factor and offset.
867 Args:
868 scale_factor: Factor to multiply raw value by
869 offset: Offset to add to raw value before scaling
871 """
872 super().__init__(scale_factor, offset)
874 @property
875 def data_size(self) -> int:
876 """Size: 3 bytes."""
877 return 3
879 def _check_range(self, raw: int) -> None:
880 """Check range for sint24."""
881 if not SINT24_MIN <= raw <= SINT24_MAX:
882 raise ValueError(f"Scaled value {raw} out of range for sint24")
885class ScaledSint32Template(ScaledTemplate):
886 """Template for scaled 32-bit signed integer with configurable resolution and offset.
888 Used for signed values encoded in 4 bytes.
889 Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7
890 """
892 _extractor = SINT32
894 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
895 """Initialize with scale factor and offset.
897 Args:
898 scale_factor: Factor to multiply raw value by
899 offset: Offset to add to raw value before scaling
901 """
902 super().__init__(scale_factor, offset)
904 @property
905 def data_size(self) -> int:
906 """Size: 4 bytes."""
907 return 4
909 def _check_range(self, raw: int) -> None:
910 """Check range for sint32."""
911 sint32_min = -(2**31)
912 sint32_max = (2**31) - 1
913 if not sint32_min <= raw <= sint32_max:
914 raise ValueError(f"Scaled value {raw} out of range for sint32")
917# =============================================================================
918# DOMAIN-SPECIFIC TEMPLATES
919# =============================================================================
922class PercentageTemplate(CodingTemplate[int]):
923 """Template for percentage values (0-100%) using uint8."""
925 @property
926 def data_size(self) -> int:
927 """Size: 1 byte."""
928 return 1
930 @property
931 def extractor(self) -> RawExtractor:
932 """Get uint8 extractor."""
933 return UINT8
935 @property
936 def translator(self) -> IdentityTranslator:
937 """Return identity translator since validation is separate from translation."""
938 return IDENTITY
940 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
941 """Parse percentage value."""
942 if len(data) < offset + 1:
943 raise InsufficientDataError("percentage", data[offset:], 1)
944 value = self.extractor.extract(data, offset)
945 if not 0 <= value <= PERCENTAGE_MAX:
946 raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX)
947 return self.translator.translate(value)
949 def encode_value(self, value: int) -> bytearray:
950 """Encode percentage value to bytes."""
951 if not 0 <= value <= PERCENTAGE_MAX:
952 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})")
953 raw = self.translator.untranslate(value)
954 return self.extractor.pack(raw)
957class TemperatureTemplate(CodingTemplate[float]):
958 """Template for standard Bluetooth SIG temperature format (sint16, 0.01°C resolution)."""
960 def __init__(self) -> None:
961 """Initialize with standard temperature resolution."""
962 self._scaled_template = ScaledSint16Template.from_letter_method(1, -2, 0)
964 @property
965 def data_size(self) -> int:
966 """Size: 2 bytes."""
967 return 2
969 @property
970 def extractor(self) -> RawExtractor:
971 """Get extractor from underlying scaled template."""
972 return self._scaled_template.extractor
974 @property
975 def translator(self) -> ValueTranslator[float]:
976 """Return the linear translator from the underlying scaled template."""
977 return self._scaled_template.translator
979 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
980 """Parse temperature in 0.01°C resolution."""
981 return self._scaled_template.decode_value(data, offset) # pylint: disable=protected-access
983 def encode_value(self, value: float) -> bytearray:
984 """Encode temperature to bytes."""
985 return self._scaled_template.encode_value(value) # pylint: disable=protected-access
988class ConcentrationTemplate(CodingTemplate[float]):
989 """Template for concentration measurements with configurable resolution.
991 Used for environmental sensors like CO2, VOC, particulate matter, etc.
992 """
994 def __init__(self, resolution: float = 1.0) -> None:
995 """Initialize with resolution.
997 Args:
998 resolution: Measurement resolution (e.g., 1.0 for integer ppm, 0.1 for 0.1 ppm)
1000 """
1001 # Convert resolution to M, d, b parameters when it fits the pattern
1002 # resolution = M * 10^d, so we find M and d such that M * 10^d = resolution
1003 if resolution == 1.0:
1004 # resolution = 1 * 10^0
1005 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=0, b=0)
1006 elif resolution == 0.1:
1007 # resolution = 1 * 10^-1
1008 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-1, b=0)
1009 elif resolution == 0.01:
1010 # resolution = 1 * 10^-2
1011 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-2, b=0)
1012 else:
1013 # Fallback to scale_factor for resolutions that don't fit M * 10^d pattern
1014 self._scaled_template = ScaledUint16Template(scale_factor=resolution)
1016 @classmethod
1017 def from_letter_method(cls, M: int, d: int, b: int = 0) -> ConcentrationTemplate:
1018 """Create instance using Bluetooth SIG M, d, b parameters.
1020 Args:
1021 M: Multiplier factor
1022 d: Decimal exponent (10^d)
1023 b: Offset to add to raw value before scaling
1025 Returns:
1026 ConcentrationTemplate instance
1028 """
1029 instance = cls.__new__(cls)
1030 instance._scaled_template = ScaledUint16Template.from_letter_method(M=M, d=d, b=b)
1031 return instance
1033 @property
1034 def data_size(self) -> int:
1035 """Size: 2 bytes."""
1036 return 2
1038 @property
1039 def extractor(self) -> RawExtractor:
1040 """Get extractor from underlying scaled template."""
1041 return self._scaled_template.extractor
1043 @property
1044 def translator(self) -> ValueTranslator[float]:
1045 """Return the linear translator from the underlying scaled template."""
1046 return self._scaled_template.translator
1048 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
1049 """Parse concentration with resolution."""
1050 return self._scaled_template.decode_value(data, offset) # pylint: disable=protected-access
1052 def encode_value(self, value: float) -> bytearray:
1053 """Encode concentration value to bytes."""
1054 return self._scaled_template.encode_value(value) # pylint: disable=protected-access
1057class PressureTemplate(CodingTemplate[float]):
1058 """Template for pressure measurements (uint32, 0.1 Pa resolution)."""
1060 def __init__(self) -> None:
1061 """Initialize with standard pressure resolution (0.1 Pa)."""
1062 self._scaled_template = ScaledUint32Template(scale_factor=0.1)
1064 @property
1065 def data_size(self) -> int:
1066 """Size: 4 bytes."""
1067 return 4
1069 @property
1070 def extractor(self) -> RawExtractor:
1071 """Get extractor from underlying scaled template."""
1072 return self._scaled_template.extractor
1074 @property
1075 def translator(self) -> ValueTranslator[float]:
1076 """Return the linear translator from the underlying scaled template."""
1077 return self._scaled_template.translator
1079 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
1080 """Parse pressure in 0.1 Pa resolution (returns Pa)."""
1081 return self._scaled_template.decode_value(data, offset) # pylint: disable=protected-access
1083 def encode_value(self, value: float) -> bytearray:
1084 """Encode pressure to bytes."""
1085 return self._scaled_template.encode_value(value) # pylint: disable=protected-access
1088class TimeDataTemplate(CodingTemplate[TimeData]):
1089 """Template for Bluetooth SIG time data parsing (10 bytes).
1091 Used for Current Time and Time with DST characteristics.
1092 Structure: Date Time (7 bytes) + Day of Week (1) + Fractions256 (1) + Adjust Reason (1)
1093 """
1095 LENGTH = 10
1096 DAY_OF_WEEK_MAX = 7
1097 FRACTIONS256_MAX = 255
1098 ADJUST_REASON_MAX = 255
1100 @property
1101 def data_size(self) -> int:
1102 """Size: 10 bytes."""
1103 return self.LENGTH
1105 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> TimeData:
1106 """Parse time data from bytes."""
1107 if len(data) < offset + self.LENGTH:
1108 raise InsufficientDataError("time data", data[offset:], self.LENGTH)
1110 # Parse Date Time (7 bytes)
1111 year = DataParser.parse_int16(data, offset, signed=False)
1112 month = data[offset + 2]
1113 day = data[offset + 3]
1115 if year == 0 or month == 0 or day == 0:
1116 date_time = None
1117 else:
1118 date_time = IEEE11073Parser.parse_timestamp(data, offset)
1120 # Parse Day of Week (1 byte)
1121 day_of_week_raw = data[offset + 7]
1122 if day_of_week_raw > self.DAY_OF_WEEK_MAX:
1123 raise ValueRangeError("day_of_week", day_of_week_raw, 0, self.DAY_OF_WEEK_MAX)
1124 day_of_week = DayOfWeek(day_of_week_raw)
1126 # Parse Fractions256 (1 byte)
1127 fractions256 = data[offset + 8]
1129 # Parse Adjust Reason (1 byte)
1130 adjust_reason = AdjustReason.from_raw(data[offset + 9])
1132 return TimeData(
1133 date_time=date_time, day_of_week=day_of_week, fractions256=fractions256, adjust_reason=adjust_reason
1134 )
1136 def encode_value(self, value: TimeData) -> bytearray:
1137 """Encode time data to bytes."""
1138 result = bytearray()
1140 # Encode Date Time (7 bytes)
1141 if value.date_time is None:
1142 result.extend(bytearray(IEEE11073Parser.TIMESTAMP_LENGTH))
1143 else:
1144 result.extend(IEEE11073Parser.encode_timestamp(value.date_time))
1146 # Encode Day of Week (1 byte)
1147 day_of_week_value = int(value.day_of_week)
1148 if day_of_week_value > self.DAY_OF_WEEK_MAX:
1149 raise ValueRangeError("day_of_week", day_of_week_value, 0, self.DAY_OF_WEEK_MAX)
1150 result.append(day_of_week_value)
1152 # Encode Fractions256 (1 byte)
1153 if value.fractions256 > self.FRACTIONS256_MAX:
1154 raise ValueRangeError("fractions256", value.fractions256, 0, self.FRACTIONS256_MAX)
1155 result.append(value.fractions256)
1157 # Encode Adjust Reason (1 byte)
1158 if int(value.adjust_reason) > self.ADJUST_REASON_MAX:
1159 raise ValueRangeError("adjust_reason", int(value.adjust_reason), 0, self.ADJUST_REASON_MAX)
1160 result.append(int(value.adjust_reason))
1162 return result
1165class IEEE11073FloatTemplate(CodingTemplate[float]):
1166 """Template for IEEE 11073 SFLOAT format (16-bit medical device float)."""
1168 @property
1169 def data_size(self) -> int:
1170 """Size: 2 bytes."""
1171 return 2
1173 @property
1174 def extractor(self) -> RawExtractor:
1175 """Get uint16 extractor for raw bits."""
1176 return UINT16
1178 @property
1179 def translator(self) -> SfloatTranslator:
1180 """Get SFLOAT translator."""
1181 return SFLOAT
1183 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
1184 """Parse IEEE 11073 SFLOAT format."""
1185 if len(data) < offset + 2:
1186 raise InsufficientDataError("IEEE11073 SFLOAT", data[offset:], 2)
1187 raw = self.extractor.extract(data, offset)
1188 return self.translator.translate(raw)
1190 def encode_value(self, value: float) -> bytearray:
1191 """Encode value to IEEE 11073 SFLOAT format."""
1192 raw = self.translator.untranslate(value)
1193 return self.extractor.pack(raw)
1196class Float32Template(CodingTemplate[float]):
1197 """Template for IEEE-754 32-bit float parsing."""
1199 @property
1200 def data_size(self) -> int:
1201 """Size: 4 bytes."""
1202 return 4
1204 @property
1205 def extractor(self) -> RawExtractor:
1206 """Get float32 extractor."""
1207 return FLOAT32
1209 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
1210 """Parse IEEE-754 32-bit float."""
1211 if len(data) < offset + 4:
1212 raise InsufficientDataError("float32", data[offset:], 4)
1213 return DataParser.parse_float32(data, offset)
1215 def encode_value(self, value: float) -> bytearray:
1216 """Encode float32 value to bytes."""
1217 return DataParser.encode_float32(float(value))
1220# =============================================================================
1221# STRING TEMPLATES
1222# =============================================================================
1225class Utf8StringTemplate(CodingTemplate[str]):
1226 """Template for UTF-8 string parsing with variable length."""
1228 def __init__(self, max_length: int = 256) -> None:
1229 """Initialize with maximum string length.
1231 Args:
1232 max_length: Maximum string length in bytes
1234 """
1235 self.max_length = max_length
1237 @property
1238 def data_size(self) -> int:
1239 """Size: Variable (0 to max_length)."""
1240 return self.max_length
1242 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> str:
1243 """Parse UTF-8 string from remaining data."""
1244 if offset >= len(data):
1245 return ""
1247 # Take remaining data from offset
1248 string_data = data[offset:]
1250 # Remove null terminator if present
1251 if b"\x00" in string_data:
1252 null_index = string_data.index(b"\x00")
1253 string_data = string_data[:null_index]
1255 try:
1256 return string_data.decode("utf-8")
1257 except UnicodeDecodeError as e:
1258 raise ValueError(f"Invalid UTF-8 string data: {e}") from e
1260 def encode_value(self, value: str) -> bytearray:
1261 """Encode string to UTF-8 bytes."""
1262 encoded = value.encode("utf-8")
1263 if len(encoded) > self.max_length:
1264 raise ValueError(f"String too long: {len(encoded)} > {self.max_length}")
1265 return bytearray(encoded)
1268class Utf16StringTemplate(CodingTemplate[str]):
1269 """Template for UTF-16LE string parsing with variable length."""
1271 # Unicode constants for UTF-16 validation
1272 UNICODE_SURROGATE_START = 0xD800
1273 UNICODE_SURROGATE_END = 0xDFFF
1274 UNICODE_BOM = "\ufeff"
1276 def __init__(self, max_length: int = 256) -> None:
1277 """Initialize with maximum string length.
1279 Args:
1280 max_length: Maximum string length in bytes (must be even)
1282 """
1283 if max_length % 2 != 0:
1284 raise ValueError("max_length must be even for UTF-16 strings")
1285 self.max_length = max_length
1287 @property
1288 def data_size(self) -> int:
1289 """Size: Variable (0 to max_length, even bytes only)."""
1290 return self.max_length
1292 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> str:
1293 """Parse UTF-16LE string from remaining data."""
1294 if offset >= len(data):
1295 return ""
1297 # Take remaining data from offset
1298 string_data = data[offset:]
1300 # Find null terminator at even positions (UTF-16 alignment)
1301 null_index = len(string_data)
1302 for i in range(0, len(string_data) - 1, 2):
1303 if string_data[i : i + 2] == bytearray(b"\x00\x00"):
1304 null_index = i
1305 break
1306 string_data = string_data[:null_index]
1308 # UTF-16 requires even number of bytes
1309 if len(string_data) % 2 != 0:
1310 raise ValueError(f"UTF-16 data must have even byte count, got {len(string_data)}")
1312 try:
1313 decoded = string_data.decode("utf-16-le")
1314 # Strip BOM if present (robustness)
1315 if decoded.startswith(self.UNICODE_BOM):
1316 decoded = decoded[1:]
1317 # Check for invalid surrogate pairs
1318 if any(self.UNICODE_SURROGATE_START <= ord(c) <= self.UNICODE_SURROGATE_END for c in decoded):
1319 raise ValueError("Invalid UTF-16LE string data: contains unpaired surrogates")
1320 return decoded
1321 except UnicodeDecodeError as e:
1322 raise ValueError(f"Invalid UTF-16LE string data: {e}") from e
1324 def encode_value(self, value: str) -> bytearray:
1325 """Encode string to UTF-16LE bytes."""
1326 encoded = value.encode("utf-16-le")
1327 if len(encoded) > self.max_length:
1328 raise ValueError(f"String too long: {len(encoded)} > {self.max_length}")
1329 return bytearray(encoded)
1332# =============================================================================
1333# VECTOR TEMPLATES
1334# =============================================================================
1337class VectorTemplate(CodingTemplate[VectorData]):
1338 """Template for 3D vector measurements (x, y, z float32 components)."""
1340 @property
1341 def data_size(self) -> int:
1342 """Size: 12 bytes (3 x float32)."""
1343 return 12
1345 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> VectorData:
1346 """Parse 3D vector data."""
1347 if len(data) < offset + 12:
1348 raise InsufficientDataError("3D vector", data[offset:], 12)
1350 x_axis = DataParser.parse_float32(data, offset)
1351 y_axis = DataParser.parse_float32(data, offset + 4)
1352 z_axis = DataParser.parse_float32(data, offset + 8)
1354 return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis)
1356 def encode_value(self, value: VectorData) -> bytearray:
1357 """Encode 3D vector data to bytes."""
1358 result = bytearray()
1359 result.extend(DataParser.encode_float32(value.x_axis))
1360 result.extend(DataParser.encode_float32(value.y_axis))
1361 result.extend(DataParser.encode_float32(value.z_axis))
1362 return result
1365class Vector2DTemplate(CodingTemplate[Vector2DData]):
1366 """Template for 2D vector measurements (x, y float32 components)."""
1368 @property
1369 def data_size(self) -> int:
1370 """Size: 8 bytes (2 x float32)."""
1371 return 8
1373 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> Vector2DData:
1374 """Parse 2D vector data."""
1375 if len(data) < offset + 8:
1376 raise InsufficientDataError("2D vector", data[offset:], 8)
1378 x_axis = DataParser.parse_float32(data, offset)
1379 y_axis = DataParser.parse_float32(data, offset + 4)
1381 return Vector2DData(x_axis=x_axis, y_axis=y_axis)
1383 def encode_value(self, value: Vector2DData) -> bytearray:
1384 """Encode 2D vector data to bytes."""
1385 result = bytearray()
1386 result.extend(DataParser.encode_float32(value.x_axis))
1387 result.extend(DataParser.encode_float32(value.y_axis))
1388 return result
1391# =============================================================================
1392# EXPORTS
1393# =============================================================================
1395__all__ = [
1396 # Protocol
1397 "CodingTemplate",
1398 # Data structures
1399 "VectorData",
1400 "Vector2DData",
1401 "TimeData",
1402 # Basic integer templates
1403 "Uint8Template",
1404 "Sint8Template",
1405 "Uint16Template",
1406 "Sint16Template",
1407 "Uint24Template",
1408 "Uint32Template",
1409 # Enum template
1410 "EnumTemplate",
1411 # Scaled templates
1412 "ScaledUint16Template",
1413 "ScaledSint16Template",
1414 "ScaledSint8Template",
1415 "ScaledUint8Template",
1416 "ScaledUint32Template",
1417 "ScaledUint24Template",
1418 "ScaledSint24Template",
1419 # Domain-specific templates
1420 "PercentageTemplate",
1421 "TemperatureTemplate",
1422 "ConcentrationTemplate",
1423 "PressureTemplate",
1424 "TimeDataTemplate",
1425 "IEEE11073FloatTemplate",
1426 "Float32Template",
1427 # String templates
1428 "Utf8StringTemplate",
1429 "Utf16StringTemplate",
1430 # Vector templates
1431 "VectorTemplate",
1432 "Vector2DTemplate",
1433]