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

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

11 

12from __future__ import annotations 

13 

14from abc import ABC, abstractmethod 

15from typing import Any 

16 

17import msgspec 

18 

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 

34 

35# ============================================================================= 

36# LEVEL 4 BASE CLASS 

37# ============================================================================= 

38 

39 

40class CodingTemplate(ABC): 

41 """Abstract base class for coding templates. 

42 

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

47 

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. 

51 

52 Args: 

53 data: Raw bytes to parse 

54 offset: Byte offset to start parsing from 

55 ctx: Optional context for parsing 

56 

57 Returns: 

58 Parsed value of appropriate type (int, float, str, bytes, or custom dataclass) 

59 

60 """ 

61 

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. 

65 

66 Args: 

67 value: Typed value to encode 

68 

69 Returns: 

70 Raw bytes representing the value 

71 

72 """ 

73 

74 @property 

75 @abstractmethod 

76 def data_size(self) -> int: 

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

78 

79 

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

81# DATA STRUCTURES 

82# ============================================================================= 

83 

84 

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

86 """3D vector measurement data.""" 

87 

88 x_axis: float 

89 y_axis: float 

90 z_axis: float 

91 

92 

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

94 """2D vector measurement data.""" 

95 

96 x_axis: float 

97 y_axis: float 

98 

99 

100# ============================================================================= 

101# BASIC INTEGER TEMPLATES 

102# ============================================================================= 

103 

104 

105class Uint8Template(CodingTemplate): 

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

107 

108 @property 

109 def data_size(self) -> int: 

110 """Size: 1 byte.""" 

111 return 1 

112 

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) 

118 

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) 

124 

125 

126class Sint8Template(CodingTemplate): 

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

128 

129 @property 

130 def data_size(self) -> int: 

131 """Size: 1 byte.""" 

132 return 1 

133 

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) 

139 

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) 

145 

146 

147class Uint16Template(CodingTemplate): 

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

149 

150 @property 

151 def data_size(self) -> int: 

152 """Size: 2 bytes.""" 

153 return 2 

154 

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) 

160 

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) 

166 

167 

168class Sint16Template(CodingTemplate): 

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

170 

171 @property 

172 def data_size(self) -> int: 

173 """Size: 2 bytes.""" 

174 return 2 

175 

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) 

181 

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) 

187 

188 

189class Uint32Template(CodingTemplate): 

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

191 

192 @property 

193 def data_size(self) -> int: 

194 """Size: 4 bytes.""" 

195 return 4 

196 

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) 

202 

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) 

208 

209 

210# ============================================================================= 

211# SCALED VALUE TEMPLATES 

212# ============================================================================= 

213 

214 

215class ScaledTemplate(CodingTemplate): 

216 """Base class for scaled integer templates. 

217 

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

219 Subclasses implement raw parsing/encoding and range checking. 

220 """ 

221 

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

223 """Initialize with scale factor and offset. 

224 

225 Args: 

226 scale_factor: Factor to multiply raw value by 

227 offset: Offset to add to raw value before scaling 

228 

229 """ 

230 self.scale_factor = scale_factor 

231 self.offset = offset 

232 

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 

237 

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) 

243 

244 @abstractmethod 

245 def _parse_raw(self, data: bytearray, offset: int) -> int: 

246 """Parse raw integer value from data.""" 

247 

248 @abstractmethod 

249 def _encode_raw(self, raw: int) -> bytearray: 

250 """Encode raw integer to bytes.""" 

251 

252 @abstractmethod 

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

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

255 

256 @classmethod 

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

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

259 

260 Args: 

261 scale_factor: Factor to multiply raw value by 

262 offset: Offset to add to raw value before scaling 

263 

264 Returns: 

265 ScaledSint8Template instance 

266 

267 """ 

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

269 

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. 

273 

274 Args: 

275 M: Multiplier factor 

276 d: Decimal exponent (10^d) 

277 b: Offset to add to raw value before scaling 

278 

279 Returns: 

280 ScaledUint16Template instance 

281 

282 """ 

283 scale_factor = M * (10**d) 

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

285 

286 

287class ScaledUint16Template(ScaledTemplate): 

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

289 

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

295 

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

297 """Initialize with scale factor and offset. 

298 

299 Args: 

300 scale_factor: Factor to multiply raw value by 

301 offset: Offset to add to raw value before scaling 

302 

303 """ 

304 super().__init__(scale_factor, offset) 

305 

306 @property 

307 def data_size(self) -> int: 

308 """Size: 2 bytes.""" 

309 return 2 

310 

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) 

316 

317 def _encode_raw(self, raw: int) -> bytearray: 

318 """Encode raw 16-bit unsigned integer.""" 

319 return DataParser.encode_int16(raw, signed=False) 

320 

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

325 

326 

327class ScaledSint16Template(ScaledTemplate): 

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

329 

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

335 

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

337 """Initialize with scale factor and offset. 

338 

339 Args: 

340 scale_factor: Factor to multiply raw value by 

341 offset: Offset to add to raw value before scaling 

342 

343 """ 

344 super().__init__(scale_factor, offset) 

345 

346 @property 

347 def data_size(self) -> int: 

348 """Size: 2 bytes.""" 

349 return 2 

350 

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) 

356 

357 def _encode_raw(self, raw: int) -> bytearray: 

358 """Encode raw 16-bit signed integer.""" 

359 return DataParser.encode_int16(raw, signed=True) 

360 

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

365 

366 

367class ScaledSint8Template(ScaledTemplate): 

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

369 

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

375 

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

377 """Initialize with scale factor and offset. 

378 

379 Args: 

380 scale_factor: Factor to multiply raw value by 

381 offset: Offset to add to raw value before scaling 

382 

383 """ 

384 super().__init__(scale_factor, offset) 

385 

386 @property 

387 def data_size(self) -> int: 

388 """Size: 1 byte.""" 

389 return 1 

390 

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) 

396 

397 def _encode_raw(self, raw: int) -> bytearray: 

398 """Encode raw 8-bit signed integer.""" 

399 return DataParser.encode_int8(raw, signed=True) 

400 

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

405 

406 

407class ScaledUint32Template(ScaledTemplate): 

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

409 

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

411 """Initialize with scale factor and offset. 

412 

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 

416 

417 """ 

418 super().__init__(scale_factor, offset) 

419 

420 @property 

421 def data_size(self) -> int: 

422 """Size: 4 bytes.""" 

423 return 4 

424 

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) 

430 

431 def _encode_raw(self, raw: int) -> bytearray: 

432 """Encode raw 32-bit unsigned integer.""" 

433 return DataParser.encode_int32(raw, signed=False) 

434 

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

439 

440 

441class ScaledUint24Template(ScaledTemplate): 

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

443 

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

447 

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

449 """Initialize with scale factor and offset. 

450 

451 Args: 

452 scale_factor: Factor to multiply raw value by 

453 offset: Offset to add to raw value before scaling 

454 

455 """ 

456 super().__init__(scale_factor, offset) 

457 

458 @property 

459 def data_size(self) -> int: 

460 """Size: 3 bytes.""" 

461 return 3 

462 

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) 

468 

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

472 

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

477 

478 

479class ScaledSint24Template(ScaledTemplate): 

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

481 

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

485 

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

487 """Initialize with scale factor and offset. 

488 

489 Args: 

490 scale_factor: Factor to multiply raw value by 

491 offset: Offset to add to raw value before scaling 

492 

493 """ 

494 super().__init__(scale_factor, offset) 

495 

496 @property 

497 def data_size(self) -> int: 

498 """Size: 3 bytes.""" 

499 return 3 

500 

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 

513 

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

522 

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

527 

528 

529# ============================================================================= 

530# DOMAIN-SPECIFIC TEMPLATES 

531# ============================================================================= 

532 

533 

534class PercentageTemplate(CodingTemplate): 

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

536 

537 @property 

538 def data_size(self) -> int: 

539 """Size: 1 byte.""" 

540 return 1 

541 

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 

550 

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) 

556 

557 

558class TemperatureTemplate(CodingTemplate): 

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

560 

561 def __init__(self) -> None: 

562 """Initialize with standard temperature resolution.""" 

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

564 

565 @property 

566 def data_size(self) -> int: 

567 """Size: 2 bytes.""" 

568 return 2 

569 

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) 

573 

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

575 """Encode temperature to bytes.""" 

576 return self._scaled_template.encode_value(value) 

577 

578 

579class ConcentrationTemplate(CodingTemplate): 

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

581 

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

583 """ 

584 

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

586 """Initialize with resolution. 

587 

588 Args: 

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

590 

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) 

606 

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. 

610 

611 Args: 

612 M: Multiplier factor 

613 d: Decimal exponent (10^d) 

614 b: Offset to add to raw value before scaling 

615 

616 Returns: 

617 ConcentrationTemplate instance 

618 

619 """ 

620 instance = cls.__new__(cls) 

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

622 return instance 

623 

624 @property 

625 def data_size(self) -> int: 

626 """Size: 2 bytes.""" 

627 return 2 

628 

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) 

632 

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

634 """Encode concentration value to bytes.""" 

635 return self._scaled_template.encode_value(value) 

636 

637 

638class PressureTemplate(CodingTemplate): 

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

640 

641 def __init__(self) -> None: 

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

643 self._scaled_template = ScaledUint32Template(scale_factor=0.1) 

644 

645 @property 

646 def data_size(self) -> int: 

647 """Size: 4 bytes.""" 

648 return 4 

649 

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) 

653 

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

655 """Encode pressure to bytes.""" 

656 return self._scaled_template.encode_value(value) 

657 

658 

659class IEEE11073FloatTemplate(CodingTemplate): 

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

661 

662 @property 

663 def data_size(self) -> int: 

664 """Size: 2 bytes.""" 

665 return 2 

666 

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) 

672 

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

674 """Encode value to IEEE 11073 SFLOAT format.""" 

675 return IEEE11073Parser.encode_sfloat(value) 

676 

677 

678class Float32Template(CodingTemplate): 

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

680 

681 @property 

682 def data_size(self) -> int: 

683 """Size: 4 bytes.""" 

684 return 4 

685 

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) 

691 

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

693 """Encode float32 value to bytes.""" 

694 return DataParser.encode_float32(float(value)) 

695 

696 

697# ============================================================================= 

698# STRING TEMPLATES 

699# ============================================================================= 

700 

701 

702class Utf8StringTemplate(CodingTemplate): 

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

704 

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

706 """Initialize with maximum string length. 

707 

708 Args: 

709 max_length: Maximum string length in bytes 

710 

711 """ 

712 self.max_length = max_length 

713 

714 @property 

715 def data_size(self) -> int: 

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

717 return self.max_length 

718 

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

723 

724 # Take remaining data from offset 

725 string_data = data[offset:] 

726 

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] 

731 

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 

736 

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) 

743 

744 

745# ============================================================================= 

746# VECTOR TEMPLATES 

747# ============================================================================= 

748 

749 

750class VectorTemplate(CodingTemplate): 

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

752 

753 @property 

754 def data_size(self) -> int: 

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

756 return 12 

757 

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

762 

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) 

766 

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

768 

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 

776 

777 

778class Vector2DTemplate(CodingTemplate): 

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

780 

781 @property 

782 def data_size(self) -> int: 

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

784 return 8 

785 

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

790 

791 x_axis = DataParser.parse_float32(data, offset) 

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

793 

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

795 

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 

802 

803 

804# ============================================================================= 

805# EXPORTS 

806# ============================================================================= 

807 

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]