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

239 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +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_kg: float | None 

63 offset: int 

64 

65 

66class GlucoseMeasurementContextBits: 

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

68 

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

70 

71 TESTER_START_BIT = 4 # Tester value starts at bit 4 

72 TESTER_BIT_WIDTH = 4 # Tester value uses 4 bits 

73 HEALTH_START_BIT = 0 # Health value starts at bit 0 

74 HEALTH_BIT_WIDTH = 4 # Health value uses 4 bits 

75 

76 

77class CarbohydrateType(IntEnum): 

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

79 

80 BREAKFAST = 1 

81 LUNCH = 2 

82 DINNER = 3 

83 SNACK = 4 

84 DRINK = 5 

85 SUPPER = 6 

86 BRUNCH = 7 

87 

88 def __str__(self) -> str: 

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

90 names = { 

91 self.BREAKFAST: "Breakfast", 

92 self.LUNCH: "Lunch", 

93 self.DINNER: "Dinner", 

94 self.SNACK: "Snack", 

95 self.DRINK: "Drink", 

96 self.SUPPER: "Supper", 

97 self.BRUNCH: "Brunch", 

98 } 

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

100 

101 

102class MealType(IntEnum): 

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

104 

105 PREPRANDIAL = 1 

106 POSTPRANDIAL = 2 

107 FASTING = 3 

108 CASUAL = 4 

109 BEDTIME = 5 

110 

111 def __str__(self) -> str: 

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

113 names = { 

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

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

116 self.FASTING: "Fasting", 

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

118 self.BEDTIME: "Bedtime", 

119 } 

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

121 

122 

123class GlucoseTester(IntEnum): 

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

125 

126 SELF = 1 

127 HEALTH_CARE_PROFESSIONAL = 2 

128 LAB_TEST = 3 

129 NOT_AVAILABLE = 15 

130 

131 def __str__(self) -> str: 

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

133 names = { 

134 self.SELF: "Self", 

135 self.HEALTH_CARE_PROFESSIONAL: "Health Care Professional", 

136 self.LAB_TEST: "Lab test", 

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

138 } 

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

140 

141 

142class HealthType(IntEnum): 

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

144 

145 MINOR_HEALTH_ISSUES = 1 

146 MAJOR_HEALTH_ISSUES = 2 

147 DURING_MENSES = 3 

148 UNDER_STRESS = 4 

149 NO_HEALTH_ISSUES = 5 

150 NOT_AVAILABLE = 15 

151 

152 def __str__(self) -> str: 

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

154 names = { 

155 self.MINOR_HEALTH_ISSUES: "Minor health issues", 

156 self.MAJOR_HEALTH_ISSUES: "Major health issues", 

157 self.DURING_MENSES: "During menses", 

158 self.UNDER_STRESS: "Under stress", 

159 self.NO_HEALTH_ISSUES: "No health issues", 

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

161 } 

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

163 

164 

165class MedicationType(IntEnum): 

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

167 

168 RAPID_ACTING_INSULIN = 1 

169 SHORT_ACTING_INSULIN = 2 

170 INTERMEDIATE_ACTING_INSULIN = 3 

171 LONG_ACTING_INSULIN = 4 

172 PRE_MIXED_INSULIN = 5 

173 

174 def __str__(self) -> str: 

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

176 names = { 

177 self.RAPID_ACTING_INSULIN: "Rapid acting insulin", 

178 self.SHORT_ACTING_INSULIN: "Short acting insulin", 

179 self.INTERMEDIATE_ACTING_INSULIN: "Intermediate acting insulin", 

180 self.LONG_ACTING_INSULIN: "Long acting insulin", 

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

182 } 

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

184 

185 

186class GlucoseMeasurementContextExtendedFlags(IntEnum): 

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

188 

189 Currently all bits are reserved for future use. 

190 """ 

191 

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

193 

194 RESERVED_BIT_0 = 0x01 

195 RESERVED_BIT_1 = 0x02 

196 RESERVED_BIT_2 = 0x04 

197 RESERVED_BIT_3 = 0x08 

198 RESERVED_BIT_4 = 0x10 

199 RESERVED_BIT_5 = 0x20 

200 RESERVED_BIT_6 = 0x40 

201 RESERVED_BIT_7 = 0x80 

202 

203 @staticmethod 

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

205 """Get description of extended flags. 

206 

207 Args: 

208 flags: Extended flags value (0-255) 

209 

210 Returns: 

211 Description string indicating all bits are reserved 

212 

213 """ 

214 if flags == 0: 

215 return "No extended flags set" 

216 

217 # All bits are currently reserved for future use 

218 bit_descriptions: list[str] = [] 

219 for bit in range(8): 

220 bit_value = 1 << bit 

221 if flags & bit_value: 

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

223 

224 return "; ".join(bit_descriptions) 

225 

226 

227class GlucoseMeasurementContextFlags(IntFlag): 

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

229 

230 EXTENDED_FLAGS_PRESENT = 0x01 

231 CARBOHYDRATE_PRESENT = 0x02 

232 MEAL_PRESENT = 0x04 

233 TESTER_HEALTH_PRESENT = 0x08 

234 EXERCISE_PRESENT = 0x10 

235 MEDICATION_PRESENT = 0x20 

236 HBA1C_PRESENT = 0x40 

237 RESERVED = 0x80 

238 

239 

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

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

242 

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

244 """ 

245 

246 sequence_number: int 

247 flags: GlucoseMeasurementContextFlags 

248 # Optional fields - will be set by parsing methods 

249 extended_flags: int | None = None 

250 carbohydrate_id: CarbohydrateType | None = None 

251 carbohydrate_kg: float | None = None 

252 meal: MealType | None = None 

253 tester: GlucoseTester | None = None 

254 health: HealthType | None = None 

255 exercise_duration_seconds: int | None = None 

256 exercise_intensity_percent: int | None = None 

257 medication_id: MedicationType | None = None 

258 medication_kg: float | None = None 

259 hba1c_percent: float | None = None 

260 

261 def __post_init__(self) -> None: 

262 """Validate glucose measurement context data.""" 

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

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

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

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

267 

268 

269class GlucoseMeasurementContextCharacteristic(BaseCharacteristic[GlucoseMeasurementContextData]): 

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

271 

272 Used to transmit additional context for glucose measurements 

273 including carbohydrate intake, exercise, medication, and HbA1c 

274 information. 

275 

276 SIG Specification Pattern: 

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

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

279 from a corresponding Glucose Measurement characteristic. 

280 """ 

281 

282 _characteristic_name: str = "Glucose Measurement Context" 

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

284 

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

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

287 

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

289 max_length: int | None = ( 

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

291 ) 

292 allow_variable_length: bool = True # Variable optional fields 

293 

294 def _decode_value( 

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

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

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

298 

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

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

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

302 

303 Args: 

304 data: Raw bytearray from BLE characteristic. 

305 ctx: Optional context providing access to Glucose Measurement characteristic 

306 validate: Whether to validate ranges (default True) 

307 for sequence number validation. 

308 

309 Returns: 

310 GlucoseMeasurementContextData containing parsed glucose context data. 

311 

312 Raises: 

313 ValueError: If data format is invalid. 

314 

315 SIG Pattern: 

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

317 a Glucose Measurement sequence_number, following the SIG specification pattern 

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

319 

320 """ 

321 flags_raw = data[0] 

322 flags = GlucoseMeasurementContextFlags(flags_raw) 

323 offset = 1 

324 

325 # Parse sequence number (2 bytes) 

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

327 offset += 2 

328 

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

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

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

332 glucose_meas = self.get_context_characteristic(ctx, GlucoseMeasurementCharacteristic) 

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

334 # Extract sequence number from GlucoseMeasurementData 

335 meas_seq = glucose_meas.sequence_number 

336 if meas_seq != sequence_number: 

337 logger.warning( 

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

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

340 sequence_number, 

341 meas_seq, 

342 ) 

343 

344 # Parse all optional fields based on flags 

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

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

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

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

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

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

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

352 

353 # Create struct with all parsed values 

354 return GlucoseMeasurementContextData( 

355 sequence_number=sequence_number, 

356 flags=flags, 

357 extended_flags=extended.extended_flags, 

358 carbohydrate_id=carb.carbohydrate_id, 

359 carbohydrate_kg=carb.carbohydrate_kg, 

360 meal=meal_result.meal, 

361 tester=tester_health.tester, 

362 health=tester_health.health, 

363 exercise_duration_seconds=exercise.exercise_duration_seconds, 

364 exercise_intensity_percent=exercise.exercise_intensity_percent, 

365 medication_id=medication.medication_id, 

366 medication_kg=medication.medication_kg, 

367 hba1c_percent=hba1c_percent, 

368 ) 

369 

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

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

372 

373 Args: 

374 data: GlucoseMeasurementContextData containing glucose measurement context data 

375 

376 Returns: 

377 Encoded bytes representing the measurement context 

378 

379 """ 

380 sequence_number = data.sequence_number 

381 if not 0 <= sequence_number <= UINT16_MAX: 

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

383 

384 # Use the flags from the data structure 

385 flags = data.flags 

386 

387 result = bytearray([flags]) 

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

389 

390 # Encode optional extended flags 

391 if data.extended_flags is not None: 

392 result.append(data.extended_flags) 

393 

394 # Encode optional carbohydrate information 

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

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

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

398 

399 # Encode optional meal information 

400 if data.meal is not None: 

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

402 

403 # Encode optional tester/health information 

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

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

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

407 ) 

408 result.append(tester_health) 

409 

410 # Encode optional exercise information 

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

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

413 result.append(data.exercise_intensity_percent) 

414 

415 # Encode optional medication information 

416 if data.medication_id is not None and data.medication_kg is not None: 

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

418 result.extend(IEEE11073Parser.encode_sfloat(data.medication_kg)) 

419 

420 # Encode optional HbA1c information 

421 if data.hba1c_percent is not None: 

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

423 

424 return result 

425 

426 def _parse_extended_flags( 

427 self, 

428 data: bytearray, 

429 flags: GlucoseMeasurementContextFlags, 

430 offset: int, 

431 ) -> ExtendedFlagsResult: 

432 """Parse optional extended flags field.""" 

433 extended_flags: int | None = None 

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

435 extended_flags = int(data[offset]) 

436 offset += 1 

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

438 

439 def _parse_carbohydrate_info( 

440 self, 

441 data: bytearray, 

442 flags: GlucoseMeasurementContextFlags, 

443 offset: int, 

444 ) -> CarbohydrateResult: 

445 """Parse optional carbohydrate information field.""" 

446 carbohydrate_id: CarbohydrateType | None = None 

447 carbohydrate_kg: float | None = None 

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

449 carb_id = data[offset] 

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

451 carbohydrate_id = CarbohydrateType(carb_id) 

452 carbohydrate_kg = carb_value 

453 offset += 3 

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

455 

456 def _parse_meal_info( 

457 self, 

458 data: bytearray, 

459 flags: GlucoseMeasurementContextFlags, 

460 offset: int, 

461 ) -> MealResult: 

462 """Parse optional meal information field.""" 

463 meal: MealType | None = None 

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

465 meal = MealType(data[offset]) 

466 offset += 1 

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

468 

469 def _parse_tester_health_info( 

470 self, 

471 data: bytearray, 

472 flags: GlucoseMeasurementContextFlags, 

473 offset: int, 

474 ) -> TesterHealthResult: 

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

476 tester: GlucoseTester | None = None 

477 health: HealthType | None = None 

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

479 tester_health = data[offset] 

480 tester_raw = BitFieldUtils.extract_bit_field( 

481 tester_health, 

482 GlucoseMeasurementContextBits.TESTER_START_BIT, 

483 GlucoseMeasurementContextBits.TESTER_BIT_WIDTH, 

484 ) # Bits 4-7 (4 bits) 

485 health_raw = BitFieldUtils.extract_bit_field( 

486 tester_health, 

487 GlucoseMeasurementContextBits.HEALTH_START_BIT, 

488 GlucoseMeasurementContextBits.HEALTH_BIT_WIDTH, 

489 ) # Bits 0-3 (4 bits) 

490 tester = GlucoseTester(tester_raw) 

491 health = HealthType(health_raw) 

492 offset += 1 

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

494 

495 def _parse_exercise_info( 

496 self, 

497 data: bytearray, 

498 flags: GlucoseMeasurementContextFlags, 

499 offset: int, 

500 ) -> ExerciseResult: 

501 """Parse optional exercise information field.""" 

502 exercise_duration_seconds: int | None = None 

503 exercise_intensity_percent: int | None = None 

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

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

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

507 offset += 3 

508 return ExerciseResult( 

509 exercise_duration_seconds=exercise_duration_seconds, 

510 exercise_intensity_percent=exercise_intensity_percent, 

511 offset=offset, 

512 ) 

513 

514 def _parse_medication_info( 

515 self, 

516 data: bytearray, 

517 flags: GlucoseMeasurementContextFlags, 

518 offset: int, 

519 ) -> MedicationResult: 

520 """Parse optional medication information field.""" 

521 medication_id: MedicationType | None = None 

522 medication_kg: float | None = None 

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

524 medication_id = MedicationType(data[offset]) 

525 medication_kg = IEEE11073Parser.parse_sfloat(data, offset + 1) 

526 offset += 3 

527 return MedicationResult(medication_id=medication_id, medication_kg=medication_kg, offset=offset) 

528 

529 def _parse_hba1c_info( 

530 self, 

531 data: bytearray, 

532 flags: GlucoseMeasurementContextFlags, 

533 offset: int, 

534 ) -> float | None: 

535 """Parse optional HbA1c information field. 

536 

537 Returns: 

538 HbA1c percentage or None 

539 

540 """ 

541 hba1c_percent: float | None = None 

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

543 hba1c_percent = IEEE11073Parser.parse_sfloat(data, offset) 

544 return hba1c_percent