Coverage for src / bluetooth_sig / gatt / characteristics / glucose_measurement_context.py: 87%

243 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

1"""Glucose Measurement Context characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from enum import IntEnum, IntFlag 

7from typing import Any, ClassVar 

8 

9import msgspec 

10 

11from ..constants import UINT8_MAX, UINT16_MAX 

12from ..context import CharacteristicContext 

13from .base import BaseCharacteristic 

14from .glucose_measurement import GlucoseMeasurementCharacteristic 

15from .utils import BitFieldUtils, DataParser, IEEE11073Parser 

16 

17logger = logging.getLogger(__name__) 

18 

19 

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

21 """Extended flags parsing result.""" 

22 

23 extended_flags: int | None 

24 offset: int 

25 

26 

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

28 """Carbohydrate information parsing result.""" 

29 

30 carbohydrate_id: CarbohydrateType | None 

31 carbohydrate_kg: float | None 

32 offset: int 

33 

34 

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

36 """Meal information parsing result.""" 

37 

38 meal: MealType | None 

39 offset: int 

40 

41 

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

43 """Tester and health information parsing result.""" 

44 

45 tester: GlucoseTester | None 

46 health: HealthType | None 

47 offset: int 

48 

49 

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

51 """Exercise information parsing result.""" 

52 

53 exercise_duration_seconds: int | None 

54 exercise_intensity_percent: int | None 

55 offset: int 

56 

57 

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

59 """Medication information parsing result.""" 

60 

61 medication_id: MedicationType | None 

62 medication_value: float | None 

63 medication_unit: str | None 

64 offset: int 

65 

66 

67class GlucoseMeasurementContextBits: 

68 """Glucose Measurement Context bit field constants.""" 

69 

70 # pylint: disable=too-few-public-methods 

71 

72 TESTER_START_BIT = 0 # Tester value starts at bit 0 (LOW nibble) 

73 TESTER_BIT_WIDTH = 4 # Tester value uses 4 bits 

74 HEALTH_START_BIT = 4 # Health value starts at bit 4 (HIGH nibble) 

75 HEALTH_BIT_WIDTH = 4 # Health value uses 4 bits 

76 

77 

78class CarbohydrateType(IntEnum): 

79 """Carbohydrate type enumeration as per Bluetooth SIG specification.""" 

80 

81 BREAKFAST = 1 

82 LUNCH = 2 

83 DINNER = 3 

84 SNACK = 4 

85 DRINK = 5 

86 SUPPER = 6 

87 BRUNCH = 7 

88 

89 def __str__(self) -> str: 

90 """Return human-readable carbohydrate type name.""" 

91 names = { 

92 self.BREAKFAST: "Breakfast", 

93 self.LUNCH: "Lunch", 

94 self.DINNER: "Dinner", 

95 self.SNACK: "Snack", 

96 self.DRINK: "Drink", 

97 self.SUPPER: "Supper", 

98 self.BRUNCH: "Brunch", 

99 } 

100 return names.get(self, "Reserved for Future Use") 

101 

102 

103class MealType(IntEnum): 

104 """Meal type enumeration as per Bluetooth SIG specification.""" 

105 

106 PREPRANDIAL = 1 

107 POSTPRANDIAL = 2 

108 FASTING = 3 

109 CASUAL = 4 

110 BEDTIME = 5 

111 

112 def __str__(self) -> str: 

113 """Return human-readable meal type name.""" 

114 names = { 

115 self.PREPRANDIAL: "Preprandial (before meal)", 

116 self.POSTPRANDIAL: "Postprandial (after meal)", 

117 self.FASTING: "Fasting", 

118 self.CASUAL: "Casual (snacks, drinks, etc.)", 

119 self.BEDTIME: "Bedtime", 

120 } 

121 return names.get(self, "Reserved for Future Use") 

122 

123 

124class GlucoseTester(IntEnum): 

125 """Glucose tester type enumeration as per Bluetooth SIG specification.""" 

126 

127 SELF = 1 

128 HEALTH_CARE_PROFESSIONAL = 2 

129 LAB_TEST = 3 

130 NOT_AVAILABLE = 15 

131 

132 def __str__(self) -> str: 

133 """Return human-readable tester type name.""" 

134 names = { 

135 self.SELF: "Self", 

136 self.HEALTH_CARE_PROFESSIONAL: "Health Care Professional", 

137 self.LAB_TEST: "Lab test", 

138 self.NOT_AVAILABLE: "Tester value not available", 

139 } 

140 return names.get(self, "Reserved for Future Use") 

141 

142 

143class HealthType(IntEnum): 

144 """Health type enumeration as per Bluetooth SIG specification.""" 

145 

146 MINOR_HEALTH_ISSUES = 1 

147 MAJOR_HEALTH_ISSUES = 2 

148 DURING_MENSES = 3 

149 UNDER_STRESS = 4 

150 NO_HEALTH_ISSUES = 5 

151 NOT_AVAILABLE = 15 

152 

153 def __str__(self) -> str: 

154 """Return human-readable health type name.""" 

155 names = { 

156 self.MINOR_HEALTH_ISSUES: "Minor health issues", 

157 self.MAJOR_HEALTH_ISSUES: "Major health issues", 

158 self.DURING_MENSES: "During menses", 

159 self.UNDER_STRESS: "Under stress", 

160 self.NO_HEALTH_ISSUES: "No health issues", 

161 self.NOT_AVAILABLE: "Health value not available", 

162 } 

163 return names.get(self, "Reserved for Future Use") 

164 

165 

166class MedicationType(IntEnum): 

167 """Medication type enumeration as per Bluetooth SIG specification.""" 

168 

169 RAPID_ACTING_INSULIN = 1 

170 SHORT_ACTING_INSULIN = 2 

171 INTERMEDIATE_ACTING_INSULIN = 3 

172 LONG_ACTING_INSULIN = 4 

173 PRE_MIXED_INSULIN = 5 

174 

175 def __str__(self) -> str: 

176 """Return human-readable medication type name.""" 

177 names = { 

178 self.RAPID_ACTING_INSULIN: "Rapid acting insulin", 

179 self.SHORT_ACTING_INSULIN: "Short acting insulin", 

180 self.INTERMEDIATE_ACTING_INSULIN: "Intermediate acting insulin", 

181 self.LONG_ACTING_INSULIN: "Long acting insulin", 

182 self.PRE_MIXED_INSULIN: "Pre-mixed insulin", 

183 } 

184 return names.get(self, "Reserved for Future Use") 

185 

186 

187class GlucoseMeasurementContextExtendedFlags(IntEnum): 

188 """Glucose Measurement Context Extended Flags constants as per Bluetooth SIG specification. 

189 

190 Currently all bits are reserved for future use. 

191 """ 

192 

193 # pylint: disable=too-few-public-methods 

194 

195 RESERVED_BIT_0 = 0x01 

196 RESERVED_BIT_1 = 0x02 

197 RESERVED_BIT_2 = 0x04 

198 RESERVED_BIT_3 = 0x08 

199 RESERVED_BIT_4 = 0x10 

200 RESERVED_BIT_5 = 0x20 

201 RESERVED_BIT_6 = 0x40 

202 RESERVED_BIT_7 = 0x80 

203 

204 @staticmethod 

205 def get_description(flags: int) -> str: 

206 """Get description of extended flags. 

207 

208 Args: 

209 flags: Extended flags value (0-255) 

210 

211 Returns: 

212 Description string indicating all bits are reserved 

213 

214 """ 

215 if flags == 0: 

216 return "No extended flags set" 

217 

218 # All bits are currently reserved for future use 

219 bit_descriptions: list[str] = [] 

220 for bit in range(8): 

221 bit_value = 1 << bit 

222 if flags & bit_value: 

223 bit_descriptions.append(f"Bit {bit} (Reserved for Future Use)") 

224 

225 return "; ".join(bit_descriptions) 

226 

227 

228class GlucoseMeasurementContextFlags(IntFlag): 

229 """Glucose Measurement Context flags as per Bluetooth SIG specification.""" 

230 

231 CARBOHYDRATE_PRESENT = 0x01 # Bit 0 

232 MEAL_PRESENT = 0x02 # Bit 1 

233 TESTER_HEALTH_PRESENT = 0x04 # Bit 2 

234 EXERCISE_PRESENT = 0x08 # Bit 3 

235 MEDICATION_PRESENT = 0x10 # Bit 4 

236 MEDICATION_UNITS = 0x20 # Bit 5: 0=kg, 1=litres 

237 HBA1C_PRESENT = 0x40 # Bit 6 

238 EXTENDED_FLAGS_PRESENT = 0x80 # Bit 7 

239 

240 

241class GlucoseMeasurementContextData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes 

242 """Parsed data from Glucose Measurement Context characteristic. 

243 

244 Used for both parsing and encoding - None values represent optional fields. 

245 """ 

246 

247 sequence_number: int 

248 flags: GlucoseMeasurementContextFlags 

249 # Optional fields - will be set by parsing methods 

250 extended_flags: int | None = None 

251 carbohydrate_id: CarbohydrateType | None = None 

252 carbohydrate_kg: float | None = None 

253 meal: MealType | None = None 

254 tester: GlucoseTester | None = None 

255 health: HealthType | None = None 

256 exercise_duration_seconds: int | None = None 

257 exercise_intensity_percent: int | None = None 

258 medication_id: MedicationType | None = None 

259 medication_value: float | None = None 

260 medication_unit: str | None = None 

261 hba1c_percent: float | None = None 

262 

263 def __post_init__(self) -> None: 

264 """Validate glucose measurement context data.""" 

265 if not 0 <= self.flags <= UINT8_MAX: 

266 raise ValueError("Flags must be a uint8 value (0-UINT8_MAX)") 

267 if not 0 <= self.sequence_number <= UINT16_MAX: 

268 raise ValueError("Sequence number must be a uint16 value (0-UINT16_MAX)") 

269 

270 

271class GlucoseMeasurementContextCharacteristic(BaseCharacteristic[GlucoseMeasurementContextData]): 

272 """Glucose Measurement Context characteristic (0x2A34). 

273 

274 Used to transmit additional context for glucose measurements 

275 including carbohydrate intake, exercise, medication, and HbA1c 

276 information. 

277 

278 SIG Specification Pattern: 

279 This characteristic depends on Glucose Measurement (0x2A18) for sequence number 

280 matching. The sequence_number field in this context must match the sequence_number 

281 from a corresponding Glucose Measurement characteristic. 

282 """ 

283 

284 _characteristic_name: str = "Glucose Measurement Context" 

285 _manual_unit: str = "various" # Multiple units in context data 

286 

287 # Declare dependency on Glucose Measurement for sequence number matching (REQUIRED) 

288 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [GlucoseMeasurementCharacteristic] 

289 

290 min_length: int | None = 3 # Flags(1) + Sequence(2) minimum 

291 max_length: int | None = ( 

292 19 # + ExtendedFlags(1) + Carb(3) + Meal(1) + TesterHealth(1) + Exercise(3) + Medication(3) + HbA1c(2) maximum 

293 ) 

294 allow_variable_length: bool = True # Variable optional fields 

295 

296 def _decode_value( 

297 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True 

298 ) -> GlucoseMeasurementContextData: # pylint: disable=too-many-locals # Complex spec with many optional context fields 

299 """Parse glucose measurement context data according to Bluetooth specification. 

300 

301 Format: Flags(1) + Sequence Number(2) + [Extended Flags(1)] + [Carbohydrate ID(1) + Carb(2)] + 

302 [Meal(1)] + [Tester-Health(1)] + [Exercise Duration(2) + Exercise Intensity(1)] + 

303 [Medication ID(1) + Medication(2)] + [HbA1c(2)]. 

304 

305 Args: 

306 data: Raw bytearray from BLE characteristic. 

307 ctx: Optional context providing access to Glucose Measurement characteristic 

308 validate: Whether to validate ranges (default True) 

309 for sequence number validation. 

310 

311 Returns: 

312 GlucoseMeasurementContextData containing parsed glucose context data. 

313 

314 Raises: 

315 ValueError: If data format is invalid. 

316 

317 SIG Pattern: 

318 When context is available, validates that this context's sequence_number matches 

319 a Glucose Measurement sequence_number, following the SIG specification pattern 

320 where contexts are paired with measurements via sequence number matching. 

321 

322 """ 

323 flags_raw = data[0] 

324 flags = GlucoseMeasurementContextFlags(flags_raw) 

325 offset = 1 

326 

327 # Parse sequence number (2 bytes) 

328 sequence_number = DataParser.parse_int16(data, offset, signed=False) 

329 offset += 2 

330 

331 # Validate sequence number matching with Glucose Measurement if context available 

332 # SIG Specification: "Contains the sequence number of the corresponding Glucose Measurement" 

333 if ctx is not None and isinstance(ctx, CharacteristicContext): 

334 glucose_meas = self.get_context_characteristic(ctx, GlucoseMeasurementCharacteristic) 

335 if glucose_meas and hasattr(glucose_meas, "sequence_number"): 

336 # Extract sequence number from GlucoseMeasurementData 

337 meas_seq = glucose_meas.sequence_number 

338 if meas_seq != sequence_number: 

339 logger.warning( 

340 "Glucose Measurement Context sequence number (%d) does not match " 

341 "Glucose Measurement sequence number (%d)", 

342 sequence_number, 

343 meas_seq, 

344 ) 

345 

346 # Parse all optional fields based on flags 

347 extended = self._parse_extended_flags(data, flags, offset) 

348 carb = self._parse_carbohydrate_info(data, flags, extended.offset) 

349 meal_result = self._parse_meal_info(data, flags, carb.offset) 

350 tester_health = self._parse_tester_health_info(data, flags, meal_result.offset) 

351 exercise = self._parse_exercise_info(data, flags, tester_health.offset) 

352 medication = self._parse_medication_info(data, flags, exercise.offset) 

353 hba1c_percent = self._parse_hba1c_info(data, flags, medication.offset) 

354 

355 # Create struct with all parsed values 

356 return GlucoseMeasurementContextData( 

357 sequence_number=sequence_number, 

358 flags=flags, 

359 extended_flags=extended.extended_flags, 

360 carbohydrate_id=carb.carbohydrate_id, 

361 carbohydrate_kg=carb.carbohydrate_kg, 

362 meal=meal_result.meal, 

363 tester=tester_health.tester, 

364 health=tester_health.health, 

365 exercise_duration_seconds=exercise.exercise_duration_seconds, 

366 exercise_intensity_percent=exercise.exercise_intensity_percent, 

367 medication_id=medication.medication_id, 

368 medication_value=medication.medication_value, 

369 medication_unit=medication.medication_unit, 

370 hba1c_percent=hba1c_percent, 

371 ) 

372 

373 def _encode_value(self, data: GlucoseMeasurementContextData) -> bytearray: 

374 """Encode glucose measurement context value back to bytes. 

375 

376 Args: 

377 data: GlucoseMeasurementContextData containing glucose measurement context data 

378 

379 Returns: 

380 Encoded bytes representing the measurement context 

381 

382 """ 

383 sequence_number = data.sequence_number 

384 if not 0 <= sequence_number <= UINT16_MAX: 

385 raise ValueError(f"Sequence number {sequence_number} exceeds uint16 range") 

386 

387 # Use the flags from the data structure 

388 flags = data.flags 

389 

390 result = bytearray([flags]) 

391 result.extend(DataParser.encode_int16(sequence_number, signed=False)) 

392 

393 # Encode optional extended flags 

394 if data.extended_flags is not None: 

395 result.append(data.extended_flags) 

396 

397 # Encode optional carbohydrate information 

398 if data.carbohydrate_id is not None and data.carbohydrate_kg is not None: 

399 result.append(int(data.carbohydrate_id)) 

400 result.extend(IEEE11073Parser.encode_sfloat(data.carbohydrate_kg)) 

401 

402 # Encode optional meal information 

403 if data.meal is not None: 

404 result.append(int(data.meal)) 

405 

406 # Encode optional tester/health information 

407 if data.tester is not None and data.health is not None: 

408 tester_health = (int(data.tester) << GlucoseMeasurementContextBits.TESTER_START_BIT) | ( 

409 int(data.health) << GlucoseMeasurementContextBits.HEALTH_START_BIT 

410 ) 

411 result.append(tester_health) 

412 

413 # Encode optional exercise information 

414 if data.exercise_duration_seconds is not None and data.exercise_intensity_percent is not None: 

415 result.extend(DataParser.encode_int16(data.exercise_duration_seconds, signed=False)) 

416 result.append(data.exercise_intensity_percent) 

417 

418 # Encode optional medication information 

419 if data.medication_id is not None and data.medication_value is not None: 

420 result.append(int(data.medication_id)) 

421 result.extend(IEEE11073Parser.encode_sfloat(data.medication_value)) 

422 

423 # Encode optional HbA1c information 

424 if data.hba1c_percent is not None: 

425 result.extend(IEEE11073Parser.encode_sfloat(data.hba1c_percent)) 

426 

427 return result 

428 

429 def _parse_extended_flags( 

430 self, 

431 data: bytearray, 

432 flags: GlucoseMeasurementContextFlags, 

433 offset: int, 

434 ) -> ExtendedFlagsResult: 

435 """Parse optional extended flags field.""" 

436 extended_flags: int | None = None 

437 if GlucoseMeasurementContextFlags.EXTENDED_FLAGS_PRESENT in flags and len(data) >= offset + 1: 

438 extended_flags = int(data[offset]) 

439 offset += 1 

440 return ExtendedFlagsResult(extended_flags=extended_flags, offset=offset) 

441 

442 def _parse_carbohydrate_info( 

443 self, 

444 data: bytearray, 

445 flags: GlucoseMeasurementContextFlags, 

446 offset: int, 

447 ) -> CarbohydrateResult: 

448 """Parse optional carbohydrate information field.""" 

449 carbohydrate_id: CarbohydrateType | None = None 

450 carbohydrate_kg: float | None = None 

451 if GlucoseMeasurementContextFlags.CARBOHYDRATE_PRESENT in flags and len(data) >= offset + 3: 

452 carb_id = data[offset] 

453 carb_value = IEEE11073Parser.parse_sfloat(data, offset + 1) 

454 carbohydrate_id = CarbohydrateType(carb_id) 

455 carbohydrate_kg = carb_value 

456 offset += 3 

457 return CarbohydrateResult(carbohydrate_id=carbohydrate_id, carbohydrate_kg=carbohydrate_kg, offset=offset) 

458 

459 def _parse_meal_info( 

460 self, 

461 data: bytearray, 

462 flags: GlucoseMeasurementContextFlags, 

463 offset: int, 

464 ) -> MealResult: 

465 """Parse optional meal information field.""" 

466 meal: MealType | None = None 

467 if GlucoseMeasurementContextFlags.MEAL_PRESENT in flags and len(data) >= offset + 1: 

468 meal = MealType(data[offset]) 

469 offset += 1 

470 return MealResult(meal=meal, offset=offset) 

471 

472 def _parse_tester_health_info( 

473 self, 

474 data: bytearray, 

475 flags: GlucoseMeasurementContextFlags, 

476 offset: int, 

477 ) -> TesterHealthResult: 

478 """Parse optional tester and health information field.""" 

479 tester: GlucoseTester | None = None 

480 health: HealthType | None = None 

481 if GlucoseMeasurementContextFlags.TESTER_HEALTH_PRESENT in flags and len(data) >= offset + 1: 

482 tester_health = data[offset] 

483 tester_raw = BitFieldUtils.extract_bit_field( 

484 tester_health, 

485 GlucoseMeasurementContextBits.TESTER_START_BIT, 

486 GlucoseMeasurementContextBits.TESTER_BIT_WIDTH, 

487 ) # Bits 4-7 (4 bits) 

488 health_raw = BitFieldUtils.extract_bit_field( 

489 tester_health, 

490 GlucoseMeasurementContextBits.HEALTH_START_BIT, 

491 GlucoseMeasurementContextBits.HEALTH_BIT_WIDTH, 

492 ) # Bits 0-3 (4 bits) 

493 tester = GlucoseTester(tester_raw) 

494 health = HealthType(health_raw) 

495 offset += 1 

496 return TesterHealthResult(tester=tester, health=health, offset=offset) 

497 

498 def _parse_exercise_info( 

499 self, 

500 data: bytearray, 

501 flags: GlucoseMeasurementContextFlags, 

502 offset: int, 

503 ) -> ExerciseResult: 

504 """Parse optional exercise information field.""" 

505 exercise_duration_seconds: int | None = None 

506 exercise_intensity_percent: int | None = None 

507 if GlucoseMeasurementContextFlags.EXERCISE_PRESENT in flags and len(data) >= offset + 3: 

508 exercise_duration_seconds = DataParser.parse_int16(data, offset, signed=False) 

509 exercise_intensity_percent = int(data[offset + 2]) 

510 offset += 3 

511 return ExerciseResult( 

512 exercise_duration_seconds=exercise_duration_seconds, 

513 exercise_intensity_percent=exercise_intensity_percent, 

514 offset=offset, 

515 ) 

516 

517 def _parse_medication_info( 

518 self, 

519 data: bytearray, 

520 flags: GlucoseMeasurementContextFlags, 

521 offset: int, 

522 ) -> MedicationResult: 

523 """Parse optional medication information field.""" 

524 medication_id: MedicationType | None = None 

525 medication_value: float | None = None 

526 medication_unit: str | None = None 

527 if GlucoseMeasurementContextFlags.MEDICATION_PRESENT in flags and len(data) >= offset + 3: 

528 medication_id = MedicationType(data[offset]) 

529 medication_value = IEEE11073Parser.parse_sfloat(data, offset + 1) 

530 medication_unit = "litres" if GlucoseMeasurementContextFlags.MEDICATION_UNITS in flags else "kg" 

531 offset += 3 

532 return MedicationResult( 

533 medication_id=medication_id, 

534 medication_value=medication_value, 

535 medication_unit=medication_unit, 

536 offset=offset, 

537 ) 

538 

539 def _parse_hba1c_info( 

540 self, 

541 data: bytearray, 

542 flags: GlucoseMeasurementContextFlags, 

543 offset: int, 

544 ) -> float | None: 

545 """Parse optional HbA1c information field. 

546 

547 Returns: 

548 HbA1c percentage or None 

549 

550 """ 

551 hba1c_percent: float | None = None 

552 if GlucoseMeasurementContextFlags.HBA1C_PRESENT in flags and len(data) >= offset + 2: 

553 hba1c_percent = IEEE11073Parser.parse_sfloat(data, offset) 

554 return hba1c_percent