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

1# mypy: warn_unused_ignores=False 

2"""Coding templates for characteristic composition patterns. 

3 

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. 

7 

8All templates follow the CodingTemplate protocol and can be used by both SIG 

9and custom characteristics through composition. 

10 

11Pipeline architecture: 

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

13 

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 

19 

20from __future__ import annotations 

21 

22from abc import ABC, abstractmethod 

23from datetime import datetime 

24from enum import IntEnum 

25from typing import Any, Generic, TypeVar 

26 

27import msgspec 

28 

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) 

66 

67# ============================================================================= 

68# TYPE VARIABLES 

69# ============================================================================= 

70 

71# Type variable for CodingTemplate generic - represents the decoded value type 

72T_co = TypeVar("T_co", covariant=True) 

73 

74# Type variable for EnumTemplate - bound to IntEnum 

75T = TypeVar("T", bound=IntEnum) 

76 

77 

78# ============================================================================= 

79# LEVEL 4 BASE CLASS 

80# ============================================================================= 

81 

82 

83class CodingTemplate(ABC, Generic[T_co]): 

84 """Abstract base class for coding templates. 

85 

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. 

89 

90 Generic over T_co, the type of value produced by _decode_value. 

91 Concrete templates specify their return type, e.g., CodingTemplate[int]. 

92 

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

97 

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. 

101 

102 Args: 

103 data: Raw bytes to parse 

104 offset: Byte offset to start parsing from 

105 ctx: Optional context for parsing 

106 

107 Returns: 

108 Parsed value of type T_co 

109 

110 """ 

111 

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. 

115 

116 Args: 

117 value: Typed value to encode 

118 

119 Returns: 

120 Raw bytes representing the value 

121 

122 """ 

123 

124 @property 

125 @abstractmethod 

126 def data_size(self) -> int: 

127 """Size of data in bytes that this template handles.""" 

128 

129 @property 

130 def extractor(self) -> RawExtractor | None: 

131 """Get the raw byte extractor for pipeline access. 

132 

133 Returns None for complex templates where extraction isn't separable. 

134 """ 

135 return None 

136 

137 @property 

138 def translator(self) -> ValueTranslator[Any] | None: 

139 """Get the value translator for pipeline access. 

140 

141 Returns None for complex templates where translation isn't separable. 

142 """ 

143 return None 

144 

145 

146# ============================================================================= 

147# DATA STRUCTURES 

148# ============================================================================= 

149 

150 

151class VectorData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

152 """3D vector measurement data.""" 

153 

154 x_axis: float 

155 y_axis: float 

156 z_axis: float 

157 

158 

159class Vector2DData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

160 """2D vector measurement data.""" 

161 

162 x_axis: float 

163 y_axis: float 

164 

165 

166class TimeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

167 """Time characteristic data structure.""" 

168 

169 date_time: datetime | None 

170 day_of_week: DayOfWeek 

171 fractions256: int 

172 adjust_reason: AdjustReason 

173 

174 

175# ============================================================================= 

176# BASIC INTEGER TEMPLATES 

177# ============================================================================= 

178 

179 

180class Uint8Template(CodingTemplate[int]): 

181 """Template for 8-bit unsigned integer parsing (0-255).""" 

182 

183 @property 

184 def data_size(self) -> int: 

185 """Size: 1 byte.""" 

186 return 1 

187 

188 @property 

189 def extractor(self) -> RawExtractor: 

190 """Get uint8 extractor.""" 

191 return UINT8 

192 

193 @property 

194 def translator(self) -> ValueTranslator[int]: 

195 """Return identity translator for no scaling.""" 

196 return IDENTITY 

197 

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) 

203 

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) 

209 

210 

211class Sint8Template(CodingTemplate[int]): 

212 """Template for 8-bit signed integer parsing (-128 to 127).""" 

213 

214 @property 

215 def data_size(self) -> int: 

216 """Size: 1 byte.""" 

217 return 1 

218 

219 @property 

220 def extractor(self) -> RawExtractor: 

221 """Get sint8 extractor.""" 

222 return SINT8 

223 

224 @property 

225 def translator(self) -> ValueTranslator[int]: 

226 """Return identity translator for no scaling.""" 

227 return IDENTITY 

228 

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) 

234 

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) 

240 

241 

242class Uint16Template(CodingTemplate[int]): 

243 """Template for 16-bit unsigned integer parsing (0-65535).""" 

244 

245 @property 

246 def data_size(self) -> int: 

247 """Size: 2 bytes.""" 

248 return 2 

249 

250 @property 

251 def extractor(self) -> RawExtractor: 

252 """Get uint16 extractor.""" 

253 return UINT16 

254 

255 @property 

256 def translator(self) -> ValueTranslator[int]: 

257 """Return identity translator for no scaling.""" 

258 return IDENTITY 

259 

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) 

265 

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) 

271 

272 

273class Sint16Template(CodingTemplate[int]): 

274 """Template for 16-bit signed integer parsing (-32768 to 32767).""" 

275 

276 @property 

277 def data_size(self) -> int: 

278 """Size: 2 bytes.""" 

279 return 2 

280 

281 @property 

282 def extractor(self) -> RawExtractor: 

283 """Get sint16 extractor.""" 

284 return SINT16 

285 

286 @property 

287 def translator(self) -> ValueTranslator[int]: 

288 """Return identity translator for no scaling.""" 

289 return IDENTITY 

290 

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) 

296 

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) 

302 

303 

304class Uint24Template(CodingTemplate[int]): 

305 """Template for 24-bit unsigned integer parsing (0-16777215).""" 

306 

307 @property 

308 def data_size(self) -> int: 

309 """Size: 3 bytes.""" 

310 return 3 

311 

312 @property 

313 def extractor(self) -> RawExtractor: 

314 """Get uint24 extractor.""" 

315 return UINT24 

316 

317 @property 

318 def translator(self) -> ValueTranslator[int]: 

319 """Return identity translator for no scaling.""" 

320 return IDENTITY 

321 

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) 

327 

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) 

333 

334 

335class Uint32Template(CodingTemplate[int]): 

336 """Template for 32-bit unsigned integer parsing.""" 

337 

338 @property 

339 def data_size(self) -> int: 

340 """Size: 4 bytes.""" 

341 return 4 

342 

343 @property 

344 def extractor(self) -> RawExtractor: 

345 """Get uint32 extractor.""" 

346 return UINT32 

347 

348 @property 

349 def translator(self) -> ValueTranslator[int]: 

350 """Return identity translator for no scaling.""" 

351 return IDENTITY 

352 

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) 

358 

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) 

364 

365 

366class EnumTemplate(CodingTemplate[T]): 

367 """Template for IntEnum encoding/decoding with configurable byte size. 

368 

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

371 

372 This template validates enum membership explicitly, supporting non-contiguous 

373 enum ranges (e.g., values 0, 2, 5, 10). 

374 

375 Pipeline Integration: 

376 bytes → [extractor] → raw_int → [IDENTITY translator] → int → enum constructor 

377 

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

399 

400 def __init__(self, enum_class: type[T], extractor: RawExtractor) -> None: 

401 """Initialize with enum class and extractor. 

402 

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 

410 

411 @property 

412 def data_size(self) -> int: 

413 """Return byte size required for encoding.""" 

414 return self._extractor.byte_size 

415 

416 @property 

417 def extractor(self) -> RawExtractor: 

418 """Return extractor for pipeline access.""" 

419 return self._extractor 

420 

421 @property 

422 def translator(self) -> ValueTranslator[int]: 

423 """Get IDENTITY translator for enums (no scaling needed).""" 

424 return IDENTITY 

425 

426 def decode_value(self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None) -> T: 

427 """Decode bytes to enum instance. 

428 

429 Args: 

430 data: Raw bytes from BLE characteristic 

431 offset: Starting offset in data buffer 

432 ctx: Optional context for parsing 

433 

434 Returns: 

435 Enum instance of type T 

436 

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) 

444 

445 # Extract raw integer value 

446 raw_value = self._extractor.extract(data, offset) 

447 

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 

457 

458 def encode_value(self, value: T | int) -> bytearray: 

459 """Encode enum instance or int to bytes. 

460 

461 Args: 

462 value: Enum instance or integer value to encode 

463 

464 Returns: 

465 Encoded bytes 

466 

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) 

472 

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 ) 

482 

483 # Pack to bytes 

484 return self._extractor.pack(int_value) 

485 

486 @classmethod 

487 def uint8(cls, enum_class: type[T]) -> EnumTemplate[T]: 

488 """Create EnumTemplate for 1-byte unsigned enum. 

489 

490 Args: 

491 enum_class: IntEnum subclass with values 0-255 

492 

493 Returns: 

494 Configured EnumTemplate instance 

495 

496 Example: 

497 >>> class Status(IntEnum): 

498 ... IDLE = 0 

499 ... ACTIVE = 1 

500 >>> template = EnumTemplate.uint8(Status) 

501 """ 

502 return cls(enum_class, UINT8) 

503 

504 @classmethod 

505 def uint16(cls, enum_class: type[T]) -> EnumTemplate[T]: 

506 """Create EnumTemplate for 2-byte unsigned enum. 

507 

508 Args: 

509 enum_class: IntEnum subclass with values 0-65535 

510 

511 Returns: 

512 Configured EnumTemplate instance 

513 

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) 

521 

522 @classmethod 

523 def uint32(cls, enum_class: type[T]) -> EnumTemplate[T]: 

524 """Create EnumTemplate for 4-byte unsigned enum. 

525 

526 Args: 

527 enum_class: IntEnum subclass with values 0-4294967295 

528 

529 Returns: 

530 Configured EnumTemplate instance 

531 """ 

532 return cls(enum_class, UINT32) 

533 

534 @classmethod 

535 def sint8(cls, enum_class: type[T]) -> EnumTemplate[T]: 

536 """Create EnumTemplate for 1-byte signed enum. 

537 

538 Args: 

539 enum_class: IntEnum subclass with values -128 to 127 

540 

541 Returns: 

542 Configured EnumTemplate instance 

543 

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) 

552 

553 @classmethod 

554 def sint16(cls, enum_class: type[T]) -> EnumTemplate[T]: 

555 """Create EnumTemplate for 2-byte signed enum. 

556 

557 Args: 

558 enum_class: IntEnum subclass with values -32768 to 32767 

559 

560 Returns: 

561 Configured EnumTemplate instance 

562 """ 

563 return cls(enum_class, SINT16) 

564 

565 @classmethod 

566 def sint32(cls, enum_class: type[T]) -> EnumTemplate[T]: 

567 """Create EnumTemplate for 4-byte signed enum. 

568 

569 Args: 

570 enum_class: IntEnum subclass with values -2147483648 to 2147483647 

571 

572 Returns: 

573 Configured EnumTemplate instance 

574 """ 

575 return cls(enum_class, SINT32) 

576 

577 

578# ============================================================================= 

579# SCALED VALUE TEMPLATES 

580# ============================================================================= 

581 

582 

583class ScaledTemplate(CodingTemplate[float]): 

584 """Base class for scaled integer templates. 

585 

586 Handles common scaling logic: value = (raw + offset) * scale_factor 

587 Subclasses implement raw parsing/encoding and range checking. 

588 

589 Exposes `extractor` and `translator` for pipeline access. 

590 """ 

591 

592 _extractor: RawExtractor 

593 _translator: LinearTranslator 

594 

595 def __init__(self, scale_factor: float, offset: int) -> None: 

596 """Initialize with scale factor and offset. 

597 

598 Args: 

599 scale_factor: Factor to multiply raw value by 

600 offset: Offset to add to raw value before scaling 

601 

602 """ 

603 self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset) 

604 

605 @property 

606 def scale_factor(self) -> float: 

607 """Get the scale factor.""" 

608 return self._translator.scale_factor 

609 

610 @property 

611 def offset(self) -> int: 

612 """Get the offset.""" 

613 return self._translator.offset 

614 

615 @property 

616 def extractor(self) -> RawExtractor: 

617 """Get the byte extractor for pipeline access.""" 

618 return self._extractor 

619 

620 @property 

621 def translator(self) -> LinearTranslator: 

622 """Get the value translator for pipeline access.""" 

623 return self._translator 

624 

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) 

629 

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) 

635 

636 @abstractmethod 

637 def _check_range(self, raw: int) -> None: 

638 """Check if raw value is in valid range.""" 

639 

640 @classmethod 

641 def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate: 

642 """Create instance using scale factor and offset. 

643 

644 Args: 

645 scale_factor: Factor to multiply raw value by 

646 offset: Offset to add to raw value before scaling 

647 

648 Returns: 

649 ScaledTemplate instance 

650 

651 """ 

652 return cls(scale_factor=scale_factor, offset=offset) 

653 

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. 

657 

658 Args: 

659 M: Multiplier factor 

660 d: Decimal exponent (10^d) 

661 b: Offset to add to raw value before scaling 

662 

663 Returns: 

664 ScaledTemplate instance 

665 

666 """ 

667 scale_factor = M * (10**d) 

668 return cls(scale_factor=scale_factor, offset=b) 

669 

670 

671class ScaledUint16Template(ScaledTemplate): 

672 """Template for scaled 16-bit unsigned integer. 

673 

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

679 

680 _extractor = UINT16 

681 

682 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

683 """Initialize with scale factor and offset. 

684 

685 Args: 

686 scale_factor: Factor to multiply raw value by 

687 offset: Offset to add to raw value before scaling 

688 

689 """ 

690 super().__init__(scale_factor, offset) 

691 

692 @property 

693 def data_size(self) -> int: 

694 """Size: 2 bytes.""" 

695 return 2 

696 

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

701 

702 

703class ScaledSint16Template(ScaledTemplate): 

704 """Template for scaled 16-bit signed integer. 

705 

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

711 

712 _extractor = SINT16 

713 

714 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

715 """Initialize with scale factor and offset. 

716 

717 Args: 

718 scale_factor: Factor to multiply raw value by 

719 offset: Offset to add to raw value before scaling 

720 

721 """ 

722 super().__init__(scale_factor, offset) 

723 

724 @property 

725 def data_size(self) -> int: 

726 """Size: 2 bytes.""" 

727 return 2 

728 

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

733 

734 

735class ScaledSint8Template(ScaledTemplate): 

736 """Template for scaled 8-bit signed integer. 

737 

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

743 

744 _extractor = SINT8 

745 

746 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

747 """Initialize with scale factor and offset. 

748 

749 Args: 

750 scale_factor: Factor to multiply raw value by 

751 offset: Offset to add to raw value before scaling 

752 

753 """ 

754 super().__init__(scale_factor, offset) 

755 

756 @property 

757 def data_size(self) -> int: 

758 """Size: 1 byte.""" 

759 return 1 

760 

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

765 

766 

767class ScaledUint8Template(ScaledTemplate): 

768 """Template for scaled 8-bit unsigned integer. 

769 

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

775 

776 _extractor = UINT8 

777 

778 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

779 """Initialize with scale factor and offset. 

780 

781 Args: 

782 scale_factor: Factor to multiply raw value by 

783 offset: Offset to add to raw value before scaling 

784 

785 """ 

786 super().__init__(scale_factor, offset) 

787 

788 @property 

789 def data_size(self) -> int: 

790 """Size: 1 byte.""" 

791 return 1 

792 

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

797 

798 

799class ScaledUint32Template(ScaledTemplate): 

800 """Template for scaled 32-bit unsigned integer with configurable resolution and offset.""" 

801 

802 _extractor = UINT32 

803 

804 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None: 

805 """Initialize with scale factor and offset. 

806 

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 

810 

811 """ 

812 super().__init__(scale_factor, offset) 

813 

814 @property 

815 def data_size(self) -> int: 

816 """Size: 4 bytes.""" 

817 return 4 

818 

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

823 

824 

825class ScaledUint24Template(ScaledTemplate): 

826 """Template for scaled 24-bit unsigned integer with configurable resolution and offset. 

827 

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

831 

832 _extractor = UINT24 

833 

834 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

835 """Initialize with scale factor and offset. 

836 

837 Args: 

838 scale_factor: Factor to multiply raw value by 

839 offset: Offset to add to raw value before scaling 

840 

841 """ 

842 super().__init__(scale_factor, offset) 

843 

844 @property 

845 def data_size(self) -> int: 

846 """Size: 3 bytes.""" 

847 return 3 

848 

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

853 

854 

855class ScaledSint24Template(ScaledTemplate): 

856 """Template for scaled 24-bit signed integer with configurable resolution and offset. 

857 

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

861 

862 _extractor = SINT24 

863 

864 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

865 """Initialize with scale factor and offset. 

866 

867 Args: 

868 scale_factor: Factor to multiply raw value by 

869 offset: Offset to add to raw value before scaling 

870 

871 """ 

872 super().__init__(scale_factor, offset) 

873 

874 @property 

875 def data_size(self) -> int: 

876 """Size: 3 bytes.""" 

877 return 3 

878 

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

883 

884 

885class ScaledSint32Template(ScaledTemplate): 

886 """Template for scaled 32-bit signed integer with configurable resolution and offset. 

887 

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

891 

892 _extractor = SINT32 

893 

894 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

895 """Initialize with scale factor and offset. 

896 

897 Args: 

898 scale_factor: Factor to multiply raw value by 

899 offset: Offset to add to raw value before scaling 

900 

901 """ 

902 super().__init__(scale_factor, offset) 

903 

904 @property 

905 def data_size(self) -> int: 

906 """Size: 4 bytes.""" 

907 return 4 

908 

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

915 

916 

917# ============================================================================= 

918# DOMAIN-SPECIFIC TEMPLATES 

919# ============================================================================= 

920 

921 

922class PercentageTemplate(CodingTemplate[int]): 

923 """Template for percentage values (0-100%) using uint8.""" 

924 

925 @property 

926 def data_size(self) -> int: 

927 """Size: 1 byte.""" 

928 return 1 

929 

930 @property 

931 def extractor(self) -> RawExtractor: 

932 """Get uint8 extractor.""" 

933 return UINT8 

934 

935 @property 

936 def translator(self) -> IdentityTranslator: 

937 """Return identity translator since validation is separate from translation.""" 

938 return IDENTITY 

939 

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) 

948 

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) 

955 

956 

957class TemperatureTemplate(CodingTemplate[float]): 

958 """Template for standard Bluetooth SIG temperature format (sint16, 0.01°C resolution).""" 

959 

960 def __init__(self) -> None: 

961 """Initialize with standard temperature resolution.""" 

962 self._scaled_template = ScaledSint16Template.from_letter_method(1, -2, 0) 

963 

964 @property 

965 def data_size(self) -> int: 

966 """Size: 2 bytes.""" 

967 return 2 

968 

969 @property 

970 def extractor(self) -> RawExtractor: 

971 """Get extractor from underlying scaled template.""" 

972 return self._scaled_template.extractor 

973 

974 @property 

975 def translator(self) -> ValueTranslator[float]: 

976 """Return the linear translator from the underlying scaled template.""" 

977 return self._scaled_template.translator 

978 

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 

982 

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 

986 

987 

988class ConcentrationTemplate(CodingTemplate[float]): 

989 """Template for concentration measurements with configurable resolution. 

990 

991 Used for environmental sensors like CO2, VOC, particulate matter, etc. 

992 """ 

993 

994 def __init__(self, resolution: float = 1.0) -> None: 

995 """Initialize with resolution. 

996 

997 Args: 

998 resolution: Measurement resolution (e.g., 1.0 for integer ppm, 0.1 for 0.1 ppm) 

999 

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) 

1015 

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. 

1019 

1020 Args: 

1021 M: Multiplier factor 

1022 d: Decimal exponent (10^d) 

1023 b: Offset to add to raw value before scaling 

1024 

1025 Returns: 

1026 ConcentrationTemplate instance 

1027 

1028 """ 

1029 instance = cls.__new__(cls) 

1030 instance._scaled_template = ScaledUint16Template.from_letter_method(M=M, d=d, b=b) 

1031 return instance 

1032 

1033 @property 

1034 def data_size(self) -> int: 

1035 """Size: 2 bytes.""" 

1036 return 2 

1037 

1038 @property 

1039 def extractor(self) -> RawExtractor: 

1040 """Get extractor from underlying scaled template.""" 

1041 return self._scaled_template.extractor 

1042 

1043 @property 

1044 def translator(self) -> ValueTranslator[float]: 

1045 """Return the linear translator from the underlying scaled template.""" 

1046 return self._scaled_template.translator 

1047 

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 

1051 

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 

1055 

1056 

1057class PressureTemplate(CodingTemplate[float]): 

1058 """Template for pressure measurements (uint32, 0.1 Pa resolution).""" 

1059 

1060 def __init__(self) -> None: 

1061 """Initialize with standard pressure resolution (0.1 Pa).""" 

1062 self._scaled_template = ScaledUint32Template(scale_factor=0.1) 

1063 

1064 @property 

1065 def data_size(self) -> int: 

1066 """Size: 4 bytes.""" 

1067 return 4 

1068 

1069 @property 

1070 def extractor(self) -> RawExtractor: 

1071 """Get extractor from underlying scaled template.""" 

1072 return self._scaled_template.extractor 

1073 

1074 @property 

1075 def translator(self) -> ValueTranslator[float]: 

1076 """Return the linear translator from the underlying scaled template.""" 

1077 return self._scaled_template.translator 

1078 

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 

1082 

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 

1086 

1087 

1088class TimeDataTemplate(CodingTemplate[TimeData]): 

1089 """Template for Bluetooth SIG time data parsing (10 bytes). 

1090 

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

1094 

1095 LENGTH = 10 

1096 DAY_OF_WEEK_MAX = 7 

1097 FRACTIONS256_MAX = 255 

1098 ADJUST_REASON_MAX = 255 

1099 

1100 @property 

1101 def data_size(self) -> int: 

1102 """Size: 10 bytes.""" 

1103 return self.LENGTH 

1104 

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) 

1109 

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] 

1114 

1115 if year == 0 or month == 0 or day == 0: 

1116 date_time = None 

1117 else: 

1118 date_time = IEEE11073Parser.parse_timestamp(data, offset) 

1119 

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) 

1125 

1126 # Parse Fractions256 (1 byte) 

1127 fractions256 = data[offset + 8] 

1128 

1129 # Parse Adjust Reason (1 byte) 

1130 adjust_reason = AdjustReason.from_raw(data[offset + 9]) 

1131 

1132 return TimeData( 

1133 date_time=date_time, day_of_week=day_of_week, fractions256=fractions256, adjust_reason=adjust_reason 

1134 ) 

1135 

1136 def encode_value(self, value: TimeData) -> bytearray: 

1137 """Encode time data to bytes.""" 

1138 result = bytearray() 

1139 

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

1145 

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) 

1151 

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) 

1156 

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

1161 

1162 return result 

1163 

1164 

1165class IEEE11073FloatTemplate(CodingTemplate[float]): 

1166 """Template for IEEE 11073 SFLOAT format (16-bit medical device float).""" 

1167 

1168 @property 

1169 def data_size(self) -> int: 

1170 """Size: 2 bytes.""" 

1171 return 2 

1172 

1173 @property 

1174 def extractor(self) -> RawExtractor: 

1175 """Get uint16 extractor for raw bits.""" 

1176 return UINT16 

1177 

1178 @property 

1179 def translator(self) -> SfloatTranslator: 

1180 """Get SFLOAT translator.""" 

1181 return SFLOAT 

1182 

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) 

1189 

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) 

1194 

1195 

1196class Float32Template(CodingTemplate[float]): 

1197 """Template for IEEE-754 32-bit float parsing.""" 

1198 

1199 @property 

1200 def data_size(self) -> int: 

1201 """Size: 4 bytes.""" 

1202 return 4 

1203 

1204 @property 

1205 def extractor(self) -> RawExtractor: 

1206 """Get float32 extractor.""" 

1207 return FLOAT32 

1208 

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) 

1214 

1215 def encode_value(self, value: float) -> bytearray: 

1216 """Encode float32 value to bytes.""" 

1217 return DataParser.encode_float32(float(value)) 

1218 

1219 

1220# ============================================================================= 

1221# STRING TEMPLATES 

1222# ============================================================================= 

1223 

1224 

1225class Utf8StringTemplate(CodingTemplate[str]): 

1226 """Template for UTF-8 string parsing with variable length.""" 

1227 

1228 def __init__(self, max_length: int = 256) -> None: 

1229 """Initialize with maximum string length. 

1230 

1231 Args: 

1232 max_length: Maximum string length in bytes 

1233 

1234 """ 

1235 self.max_length = max_length 

1236 

1237 @property 

1238 def data_size(self) -> int: 

1239 """Size: Variable (0 to max_length).""" 

1240 return self.max_length 

1241 

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

1246 

1247 # Take remaining data from offset 

1248 string_data = data[offset:] 

1249 

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] 

1254 

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 

1259 

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) 

1266 

1267 

1268class Utf16StringTemplate(CodingTemplate[str]): 

1269 """Template for UTF-16LE string parsing with variable length.""" 

1270 

1271 # Unicode constants for UTF-16 validation 

1272 UNICODE_SURROGATE_START = 0xD800 

1273 UNICODE_SURROGATE_END = 0xDFFF 

1274 UNICODE_BOM = "\ufeff" 

1275 

1276 def __init__(self, max_length: int = 256) -> None: 

1277 """Initialize with maximum string length. 

1278 

1279 Args: 

1280 max_length: Maximum string length in bytes (must be even) 

1281 

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 

1286 

1287 @property 

1288 def data_size(self) -> int: 

1289 """Size: Variable (0 to max_length, even bytes only).""" 

1290 return self.max_length 

1291 

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

1296 

1297 # Take remaining data from offset 

1298 string_data = data[offset:] 

1299 

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] 

1307 

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

1311 

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 

1323 

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) 

1330 

1331 

1332# ============================================================================= 

1333# VECTOR TEMPLATES 

1334# ============================================================================= 

1335 

1336 

1337class VectorTemplate(CodingTemplate[VectorData]): 

1338 """Template for 3D vector measurements (x, y, z float32 components).""" 

1339 

1340 @property 

1341 def data_size(self) -> int: 

1342 """Size: 12 bytes (3 x float32).""" 

1343 return 12 

1344 

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) 

1349 

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) 

1353 

1354 return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis) 

1355 

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 

1363 

1364 

1365class Vector2DTemplate(CodingTemplate[Vector2DData]): 

1366 """Template for 2D vector measurements (x, y float32 components).""" 

1367 

1368 @property 

1369 def data_size(self) -> int: 

1370 """Size: 8 bytes (2 x float32).""" 

1371 return 8 

1372 

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) 

1377 

1378 x_axis = DataParser.parse_float32(data, offset) 

1379 y_axis = DataParser.parse_float32(data, offset + 4) 

1380 

1381 return Vector2DData(x_axis=x_axis, y_axis=y_axis) 

1382 

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 

1389 

1390 

1391# ============================================================================= 

1392# EXPORTS 

1393# ============================================================================= 

1394 

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]