Coverage for src/bluetooth_sig/gatt/characteristics/templates.py: 77%
334 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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.
10"""
12from __future__ import annotations
14from abc import ABC, abstractmethod
15from typing import Any
17import msgspec
19from ..constants import (
20 PERCENTAGE_MAX,
21 SINT8_MAX,
22 SINT8_MIN,
23 SINT16_MAX,
24 SINT16_MIN,
25 SINT24_MAX,
26 SINT24_MIN,
27 UINT8_MAX,
28 UINT16_MAX,
29 UINT24_MAX,
30 UINT32_MAX,
31)
32from ..context import CharacteristicContext
33from .utils import DataParser, IEEE11073Parser
35# =============================================================================
36# LEVEL 4 BASE CLASS
37# =============================================================================
40class CodingTemplate(ABC):
41 """Abstract base class for coding templates.
43 Templates are pure coding utilities that don't inherit from BaseCharacteristic.
44 They provide coding strategies that can be injected into characteristics.
45 All templates MUST inherit from this base class and implement the required methods.
46 """
48 @abstractmethod
49 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> Any: # noqa: ANN401 # Returns various types (int, float, str, dataclass)
50 """Decode raw bytes to typed value.
52 Args:
53 data: Raw bytes to parse
54 offset: Byte offset to start parsing from
55 ctx: Optional context for parsing
57 Returns:
58 Parsed value of appropriate type (int, float, str, bytes, or custom dataclass)
60 """
62 @abstractmethod
63 def encode_value(self, value: Any) -> bytearray: # noqa: ANN401 # Accepts various value types (int, float, str, dataclass)
64 """Encode typed value to raw bytes.
66 Args:
67 value: Typed value to encode
69 Returns:
70 Raw bytes representing the value
72 """
74 @property
75 @abstractmethod
76 def data_size(self) -> int:
77 """Size of data in bytes that this template handles."""
80# =============================================================================
81# DATA STRUCTURES
82# =============================================================================
85class VectorData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
86 """3D vector measurement data."""
88 x_axis: float
89 y_axis: float
90 z_axis: float
93class Vector2DData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
94 """2D vector measurement data."""
96 x_axis: float
97 y_axis: float
100# =============================================================================
101# BASIC INTEGER TEMPLATES
102# =============================================================================
105class Uint8Template(CodingTemplate):
106 """Template for 8-bit unsigned integer parsing (0-255)."""
108 @property
109 def data_size(self) -> int:
110 """Size: 1 byte."""
111 return 1
113 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
114 """Parse 8-bit unsigned integer."""
115 if len(data) < offset + 1:
116 raise ValueError("Insufficient data for uint8 parsing")
117 return DataParser.parse_int8(data, offset, signed=False)
119 def encode_value(self, value: int) -> bytearray:
120 """Encode uint8 value to bytes."""
121 if not 0 <= value <= UINT8_MAX:
122 raise ValueError(f"Value {value} out of range for uint8 (0-{UINT8_MAX})")
123 return DataParser.encode_int8(value, signed=False)
126class Sint8Template(CodingTemplate):
127 """Template for 8-bit signed integer parsing (-128 to 127)."""
129 @property
130 def data_size(self) -> int:
131 """Size: 1 byte."""
132 return 1
134 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
135 """Parse 8-bit signed integer."""
136 if len(data) < offset + 1:
137 raise ValueError("Insufficient data for sint8 parsing")
138 return DataParser.parse_int8(data, offset, signed=True)
140 def encode_value(self, value: int) -> bytearray:
141 """Encode sint8 value to bytes."""
142 if not SINT8_MIN <= value <= SINT8_MAX:
143 raise ValueError(f"Value {value} out of range for sint8 ({SINT8_MIN} to {SINT8_MAX})")
144 return DataParser.encode_int8(value, signed=True)
147class Uint16Template(CodingTemplate):
148 """Template for 16-bit unsigned integer parsing (0-65535)."""
150 @property
151 def data_size(self) -> int:
152 """Size: 2 bytes."""
153 return 2
155 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
156 """Parse 16-bit unsigned integer."""
157 if len(data) < offset + 2:
158 raise ValueError("Insufficient data for uint16 parsing")
159 return DataParser.parse_int16(data, offset, signed=False)
161 def encode_value(self, value: int) -> bytearray:
162 """Encode uint16 value to bytes."""
163 if not 0 <= value <= UINT16_MAX:
164 raise ValueError(f"Value {value} out of range for uint16 (0-{UINT16_MAX})")
165 return DataParser.encode_int16(value, signed=False)
168class Sint16Template(CodingTemplate):
169 """Template for 16-bit signed integer parsing (-32768 to 32767)."""
171 @property
172 def data_size(self) -> int:
173 """Size: 2 bytes."""
174 return 2
176 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
177 """Parse 16-bit signed integer."""
178 if len(data) < offset + 2:
179 raise ValueError("Insufficient data for sint16 parsing")
180 return DataParser.parse_int16(data, offset, signed=True)
182 def encode_value(self, value: int) -> bytearray:
183 """Encode sint16 value to bytes."""
184 if not SINT16_MIN <= value <= SINT16_MAX:
185 raise ValueError(f"Value {value} out of range for sint16 ({SINT16_MIN} to {SINT16_MAX})")
186 return DataParser.encode_int16(value, signed=True)
189class Uint32Template(CodingTemplate):
190 """Template for 32-bit unsigned integer parsing."""
192 @property
193 def data_size(self) -> int:
194 """Size: 4 bytes."""
195 return 4
197 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
198 """Parse 32-bit unsigned integer."""
199 if len(data) < offset + 4:
200 raise ValueError("Insufficient data for uint32 parsing")
201 return DataParser.parse_int32(data, offset, signed=False)
203 def encode_value(self, value: int) -> bytearray:
204 """Encode uint32 value to bytes."""
205 if not 0 <= value <= UINT32_MAX:
206 raise ValueError(f"Value {value} out of range for uint32 (0-{UINT32_MAX})")
207 return DataParser.encode_int32(value, signed=False)
210# =============================================================================
211# SCALED VALUE TEMPLATES
212# =============================================================================
215class ScaledTemplate(CodingTemplate):
216 """Base class for scaled integer templates.
218 Handles common scaling logic: value = (raw + offset) * scale_factor
219 Subclasses implement raw parsing/encoding and range checking.
220 """
222 def __init__(self, scale_factor: float, offset: int) -> None:
223 """Initialize with scale factor and offset.
225 Args:
226 scale_factor: Factor to multiply raw value by
227 offset: Offset to add to raw value before scaling
229 """
230 self.scale_factor = scale_factor
231 self.offset = offset
233 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
234 """Parse scaled integer value."""
235 raw_value = self._parse_raw(data, offset)
236 return (raw_value + self.offset) * self.scale_factor
238 def encode_value(self, value: float) -> bytearray:
239 """Encode scaled value to bytes."""
240 raw_value = int((value / self.scale_factor) - self.offset)
241 self._check_range(raw_value)
242 return self._encode_raw(raw_value)
244 @abstractmethod
245 def _parse_raw(self, data: bytearray, offset: int) -> int:
246 """Parse raw integer value from data."""
248 @abstractmethod
249 def _encode_raw(self, raw: int) -> bytearray:
250 """Encode raw integer to bytes."""
252 @abstractmethod
253 def _check_range(self, raw: int) -> None:
254 """Check if raw value is in valid range."""
256 @classmethod
257 def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate:
258 """Create instance using scale factor and offset.
260 Args:
261 scale_factor: Factor to multiply raw value by
262 offset: Offset to add to raw value before scaling
264 Returns:
265 ScaledSint8Template instance
267 """
268 return cls(scale_factor=scale_factor, offset=offset)
270 @classmethod
271 def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate:
272 """Create instance using Bluetooth SIG M, d, b parameters.
274 Args:
275 M: Multiplier factor
276 d: Decimal exponent (10^d)
277 b: Offset to add to raw value before scaling
279 Returns:
280 ScaledUint16Template instance
282 """
283 scale_factor = M * (10**d)
284 return cls(scale_factor=scale_factor, offset=b)
287class ScaledUint16Template(ScaledTemplate):
288 """Template for scaled 16-bit unsigned integer.
290 Used for values that need decimal precision encoded as integers.
291 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
292 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
293 Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
294 """
296 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
297 """Initialize with scale factor and offset.
299 Args:
300 scale_factor: Factor to multiply raw value by
301 offset: Offset to add to raw value before scaling
303 """
304 super().__init__(scale_factor, offset)
306 @property
307 def data_size(self) -> int:
308 """Size: 2 bytes."""
309 return 2
311 def _parse_raw(self, data: bytearray, offset: int) -> int:
312 """Parse raw 16-bit unsigned integer."""
313 if len(data) < offset + 2:
314 raise ValueError("Insufficient data for scaled uint16 parsing")
315 return DataParser.parse_int16(data, offset, signed=False)
317 def _encode_raw(self, raw: int) -> bytearray:
318 """Encode raw 16-bit unsigned integer."""
319 return DataParser.encode_int16(raw, signed=False)
321 def _check_range(self, raw: int) -> None:
322 """Check range for uint16."""
323 if not 0 <= raw <= UINT16_MAX:
324 raise ValueError(f"Scaled value {raw} out of range for uint16")
327class ScaledSint16Template(ScaledTemplate):
328 """Template for scaled 16-bit signed integer.
330 Used for signed values that need decimal precision encoded as integers.
331 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
332 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
333 Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
334 """
336 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
337 """Initialize with scale factor and offset.
339 Args:
340 scale_factor: Factor to multiply raw value by
341 offset: Offset to add to raw value before scaling
343 """
344 super().__init__(scale_factor, offset)
346 @property
347 def data_size(self) -> int:
348 """Size: 2 bytes."""
349 return 2
351 def _parse_raw(self, data: bytearray, offset: int) -> int:
352 """Parse raw 16-bit signed integer."""
353 if len(data) < offset + 2:
354 raise ValueError("Insufficient data for scaled sint16 parsing")
355 return DataParser.parse_int16(data, offset, signed=True)
357 def _encode_raw(self, raw: int) -> bytearray:
358 """Encode raw 16-bit signed integer."""
359 return DataParser.encode_int16(raw, signed=True)
361 def _check_range(self, raw: int) -> None:
362 """Check range for sint16."""
363 if not SINT16_MIN <= raw <= SINT16_MAX:
364 raise ValueError(f"Scaled value {raw} out of range for sint16")
367class ScaledSint8Template(ScaledTemplate):
368 """Template for scaled 8-bit signed integer.
370 Used for signed values that need decimal precision encoded as integers.
371 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters.
372 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b)
373 Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0
374 """
376 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
377 """Initialize with scale factor and offset.
379 Args:
380 scale_factor: Factor to multiply raw value by
381 offset: Offset to add to raw value before scaling
383 """
384 super().__init__(scale_factor, offset)
386 @property
387 def data_size(self) -> int:
388 """Size: 1 byte."""
389 return 1
391 def _parse_raw(self, data: bytearray, offset: int) -> int:
392 """Parse raw 8-bit signed integer."""
393 if len(data) < offset + 1:
394 raise ValueError("Insufficient data for scaled sint8 parsing")
395 return DataParser.parse_int8(data, offset, signed=True)
397 def _encode_raw(self, raw: int) -> bytearray:
398 """Encode raw 8-bit signed integer."""
399 return DataParser.encode_int8(raw, signed=True)
401 def _check_range(self, raw: int) -> None:
402 """Check range for sint8."""
403 if not SINT8_MIN <= raw <= SINT8_MAX:
404 raise ValueError(f"Scaled value {raw} out of range for sint8")
407class ScaledUint32Template(ScaledTemplate):
408 """Template for scaled 32-bit unsigned integer with configurable resolution and offset."""
410 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None:
411 """Initialize with scale factor and offset.
413 Args:
414 scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place)
415 offset: Offset to add to raw value before scaling
417 """
418 super().__init__(scale_factor, offset)
420 @property
421 def data_size(self) -> int:
422 """Size: 4 bytes."""
423 return 4
425 def _parse_raw(self, data: bytearray, offset: int) -> int:
426 """Parse raw 32-bit unsigned integer."""
427 if len(data) < offset + 4:
428 raise ValueError("Insufficient data for scaled uint32 parsing")
429 return DataParser.parse_int32(data, offset, signed=False)
431 def _encode_raw(self, raw: int) -> bytearray:
432 """Encode raw 32-bit unsigned integer."""
433 return DataParser.encode_int32(raw, signed=False)
435 def _check_range(self, raw: int) -> None:
436 """Check range for uint32."""
437 if not 0 <= raw <= UINT32_MAX:
438 raise ValueError(f"Scaled value {raw} out of range for uint32")
441class ScaledUint24Template(ScaledTemplate):
442 """Template for scaled 24-bit unsigned integer with configurable resolution and offset.
444 Used for values encoded in 3 bytes as unsigned integers.
445 Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0
446 """
448 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None:
449 """Initialize with scale factor and offset.
451 Args:
452 scale_factor: Factor to multiply raw value by
453 offset: Offset to add to raw value before scaling
455 """
456 super().__init__(scale_factor, offset)
458 @property
459 def data_size(self) -> int:
460 """Size: 3 bytes."""
461 return 3
463 def _parse_raw(self, data: bytearray, offset: int) -> int:
464 """Parse raw 24-bit unsigned integer."""
465 if len(data) < offset + 3:
466 raise ValueError("Insufficient data for scaled uint24 parsing")
467 return int.from_bytes(data[offset : offset + 3], byteorder="little", signed=False)
469 def _encode_raw(self, raw: int) -> bytearray:
470 """Encode raw 24-bit unsigned integer."""
471 return bytearray(raw.to_bytes(3, byteorder="little", signed=False))
473 def _check_range(self, raw: int) -> None:
474 """Check range for uint24."""
475 if not 0 <= raw <= UINT24_MAX:
476 raise ValueError(f"Scaled value {raw} out of range for uint24")
479class ScaledSint24Template(ScaledTemplate):
480 """Template for scaled 24-bit signed integer with configurable resolution and offset.
482 Used for signed values encoded in 3 bytes.
483 Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0
484 """
486 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None:
487 """Initialize with scale factor and offset.
489 Args:
490 scale_factor: Factor to multiply raw value by
491 offset: Offset to add to raw value before scaling
493 """
494 super().__init__(scale_factor, offset)
496 @property
497 def data_size(self) -> int:
498 """Size: 3 bytes."""
499 return 3
501 def _parse_raw(self, data: bytearray, offset: int) -> int:
502 """Parse raw 24-bit signed integer."""
503 if len(data) < offset + 3:
504 raise ValueError("Insufficient data for scaled sint24 parsing")
505 # Parse as unsigned first
506 raw_unsigned = int.from_bytes(data[offset : offset + 3], byteorder="little", signed=False)
507 # Convert to signed using two's complement
508 if raw_unsigned >= 0x800000: # Sign bit set (2^23)
509 raw_value = raw_unsigned - 0x1000000 # 2^24
510 else:
511 raw_value = raw_unsigned
512 return raw_value
514 def _encode_raw(self, raw: int) -> bytearray:
515 """Encode raw 24-bit signed integer."""
516 # Convert to unsigned representation if negative
517 if raw < 0:
518 raw_unsigned = raw + 0x1000000 # 2^24
519 else:
520 raw_unsigned = raw
521 return bytearray(raw_unsigned.to_bytes(3, byteorder="little", signed=False))
523 def _check_range(self, raw: int) -> None:
524 """Check range for sint24."""
525 if not SINT24_MIN <= raw <= SINT24_MAX:
526 raise ValueError(f"Scaled value {raw} out of range for sint24")
529# =============================================================================
530# DOMAIN-SPECIFIC TEMPLATES
531# =============================================================================
534class PercentageTemplate(CodingTemplate):
535 """Template for percentage values (0-100%) using uint8."""
537 @property
538 def data_size(self) -> int:
539 """Size: 1 byte."""
540 return 1
542 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> int:
543 """Parse percentage value."""
544 if len(data) < offset + 1:
545 raise ValueError("Insufficient data for percentage parsing")
546 value = DataParser.parse_int8(data, offset, signed=False)
547 if not 0 <= value <= PERCENTAGE_MAX:
548 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})")
549 return value
551 def encode_value(self, value: int) -> bytearray:
552 """Encode percentage value to bytes."""
553 if not 0 <= value <= PERCENTAGE_MAX:
554 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})")
555 return DataParser.encode_int8(value, signed=False)
558class TemperatureTemplate(CodingTemplate):
559 """Template for standard Bluetooth SIG temperature format (sint16, 0.01°C resolution)."""
561 def __init__(self) -> None:
562 """Initialize with standard temperature resolution."""
563 self._scaled_template = ScaledSint16Template.from_letter_method(1, -2, 0)
565 @property
566 def data_size(self) -> int:
567 """Size: 2 bytes."""
568 return 2
570 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
571 """Parse temperature in 0.01°C resolution."""
572 return self._scaled_template.decode_value(data, offset)
574 def encode_value(self, value: float) -> bytearray:
575 """Encode temperature to bytes."""
576 return self._scaled_template.encode_value(value)
579class ConcentrationTemplate(CodingTemplate):
580 """Template for concentration measurements with configurable resolution.
582 Used for environmental sensors like CO2, VOC, particulate matter, etc.
583 """
585 def __init__(self, resolution: float = 1.0) -> None:
586 """Initialize with resolution.
588 Args:
589 resolution: Measurement resolution (e.g., 1.0 for integer ppm, 0.1 for 0.1 ppm)
591 """
592 # Convert resolution to M, d, b parameters when it fits the pattern
593 # resolution = M * 10^d, so we find M and d such that M * 10^d = resolution
594 if resolution == 1.0:
595 # resolution = 1 * 10^0
596 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=0, b=0)
597 elif resolution == 0.1:
598 # resolution = 1 * 10^-1
599 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-1, b=0)
600 elif resolution == 0.01:
601 # resolution = 1 * 10^-2
602 self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-2, b=0)
603 else:
604 # Fallback to scale_factor for resolutions that don't fit M * 10^d pattern
605 self._scaled_template = ScaledUint16Template(scale_factor=resolution)
607 @classmethod
608 def from_letter_method(cls, M: int, d: int, b: int = 0) -> ConcentrationTemplate:
609 """Create instance using Bluetooth SIG M, d, b parameters.
611 Args:
612 M: Multiplier factor
613 d: Decimal exponent (10^d)
614 b: Offset to add to raw value before scaling
616 Returns:
617 ConcentrationTemplate instance
619 """
620 instance = cls.__new__(cls)
621 instance._scaled_template = ScaledUint16Template.from_letter_method(M=M, d=d, b=b)
622 return instance
624 @property
625 def data_size(self) -> int:
626 """Size: 2 bytes."""
627 return 2
629 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
630 """Parse concentration with resolution."""
631 return self._scaled_template.decode_value(data, offset)
633 def encode_value(self, value: float) -> bytearray:
634 """Encode concentration value to bytes."""
635 return self._scaled_template.encode_value(value)
638class PressureTemplate(CodingTemplate):
639 """Template for pressure measurements (uint32, 0.1 Pa resolution)."""
641 def __init__(self) -> None:
642 """Initialize with standard pressure resolution (0.1 Pa)."""
643 self._scaled_template = ScaledUint32Template(scale_factor=0.1)
645 @property
646 def data_size(self) -> int:
647 """Size: 4 bytes."""
648 return 4
650 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
651 """Parse pressure in 0.1 Pa resolution (returns Pa)."""
652 return self._scaled_template.decode_value(data, offset)
654 def encode_value(self, value: float) -> bytearray:
655 """Encode pressure to bytes."""
656 return self._scaled_template.encode_value(value)
659class IEEE11073FloatTemplate(CodingTemplate):
660 """Template for IEEE 11073 SFLOAT format (16-bit medical device float)."""
662 @property
663 def data_size(self) -> int:
664 """Size: 2 bytes."""
665 return 2
667 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
668 """Parse IEEE 11073 SFLOAT format."""
669 if len(data) < offset + 2:
670 raise ValueError("Insufficient data for IEEE11073 SFLOAT parsing")
671 return IEEE11073Parser.parse_sfloat(data, offset)
673 def encode_value(self, value: float) -> bytearray:
674 """Encode value to IEEE 11073 SFLOAT format."""
675 return IEEE11073Parser.encode_sfloat(value)
678class Float32Template(CodingTemplate):
679 """Template for IEEE-754 32-bit float parsing."""
681 @property
682 def data_size(self) -> int:
683 """Size: 4 bytes."""
684 return 4
686 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> float:
687 """Parse IEEE-754 32-bit float."""
688 if len(data) < offset + 4:
689 raise ValueError("Insufficient data for float32 parsing")
690 return DataParser.parse_float32(data, offset)
692 def encode_value(self, value: float) -> bytearray:
693 """Encode float32 value to bytes."""
694 return DataParser.encode_float32(float(value))
697# =============================================================================
698# STRING TEMPLATES
699# =============================================================================
702class Utf8StringTemplate(CodingTemplate):
703 """Template for UTF-8 string parsing with variable length."""
705 def __init__(self, max_length: int = 256) -> None:
706 """Initialize with maximum string length.
708 Args:
709 max_length: Maximum string length in bytes
711 """
712 self.max_length = max_length
714 @property
715 def data_size(self) -> int:
716 """Size: Variable (0 to max_length)."""
717 return self.max_length
719 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> str:
720 """Parse UTF-8 string from remaining data."""
721 if offset >= len(data):
722 return ""
724 # Take remaining data from offset
725 string_data = data[offset:]
727 # Remove null terminator if present
728 if b"\x00" in string_data:
729 null_index = string_data.index(b"\x00")
730 string_data = string_data[:null_index]
732 try:
733 return string_data.decode("utf-8")
734 except UnicodeDecodeError as e:
735 raise ValueError(f"Invalid UTF-8 string data: {e}") from e
737 def encode_value(self, value: str) -> bytearray:
738 """Encode string to UTF-8 bytes."""
739 encoded = value.encode("utf-8")
740 if len(encoded) > self.max_length:
741 raise ValueError(f"String too long: {len(encoded)} > {self.max_length}")
742 return bytearray(encoded)
745# =============================================================================
746# VECTOR TEMPLATES
747# =============================================================================
750class VectorTemplate(CodingTemplate):
751 """Template for 3D vector measurements (x, y, z float32 components)."""
753 @property
754 def data_size(self) -> int:
755 """Size: 12 bytes (3 x float32)."""
756 return 12
758 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> VectorData:
759 """Parse 3D vector data."""
760 if len(data) < offset + 12:
761 raise ValueError("Insufficient data for 3D vector parsing (need 12 bytes)")
763 x_axis = DataParser.parse_float32(data, offset)
764 y_axis = DataParser.parse_float32(data, offset + 4)
765 z_axis = DataParser.parse_float32(data, offset + 8)
767 return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis)
769 def encode_value(self, value: VectorData) -> bytearray:
770 """Encode 3D vector data to bytes."""
771 result = bytearray()
772 result.extend(DataParser.encode_float32(value.x_axis))
773 result.extend(DataParser.encode_float32(value.y_axis))
774 result.extend(DataParser.encode_float32(value.z_axis))
775 return result
778class Vector2DTemplate(CodingTemplate):
779 """Template for 2D vector measurements (x, y float32 components)."""
781 @property
782 def data_size(self) -> int:
783 """Size: 8 bytes (2 x float32)."""
784 return 8
786 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> Vector2DData:
787 """Parse 2D vector data."""
788 if len(data) < offset + 8:
789 raise ValueError("Insufficient data for 2D vector parsing (need 8 bytes)")
791 x_axis = DataParser.parse_float32(data, offset)
792 y_axis = DataParser.parse_float32(data, offset + 4)
794 return Vector2DData(x_axis=x_axis, y_axis=y_axis)
796 def encode_value(self, value: Vector2DData) -> bytearray:
797 """Encode 2D vector data to bytes."""
798 result = bytearray()
799 result.extend(DataParser.encode_float32(value.x_axis))
800 result.extend(DataParser.encode_float32(value.y_axis))
801 return result
804# =============================================================================
805# EXPORTS
806# =============================================================================
808__all__ = [
809 # Protocol
810 "CodingTemplate",
811 # Data structures
812 "VectorData",
813 "Vector2DData",
814 # Basic integer templates
815 "Uint8Template",
816 "Sint8Template",
817 "Uint16Template",
818 "Sint16Template",
819 "Uint32Template",
820 # Scaled templates
821 "ScaledUint16Template",
822 "ScaledSint16Template",
823 "ScaledSint8Template",
824 "ScaledUint32Template",
825 "ScaledUint24Template",
826 "ScaledSint24Template",
827 # Domain-specific templates
828 "PercentageTemplate",
829 "TemperatureTemplate",
830 "ConcentrationTemplate",
831 "PressureTemplate",
832 "IEEE11073FloatTemplate",
833 "Float32Template",
834 # String templates
835 "Utf8StringTemplate",
836 # Vector templates
837 "VectorTemplate",
838 "Vector2DTemplate",
839]