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

240 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

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

2 

3from __future__ import annotations 

4 

5import logging 

6from enum import IntEnum, IntFlag 

7 

8import msgspec 

9 

10from ..constants import UINT8_MAX, UINT16_MAX 

11from ..context import CharacteristicContext 

12from .base import BaseCharacteristic 

13from .glucose_measurement import GlucoseMeasurementCharacteristic 

14from .utils import BitFieldUtils, DataParser, IEEE11073Parser 

15 

16logger = logging.getLogger(__name__) 

17 

18 

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

20 """Extended flags parsing result.""" 

21 

22 extended_flags: int | None 

23 offset: int 

24 

25 

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

27 """Carbohydrate information parsing result.""" 

28 

29 carbohydrate_id: CarbohydrateType | None 

30 carbohydrate_kg: float | None 

31 offset: int 

32 

33 

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

35 """Meal information parsing result.""" 

36 

37 meal: MealType | None 

38 offset: int 

39 

40 

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

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

43 

44 tester: GlucoseTester | None 

45 health: HealthType | None 

46 offset: int 

47 

48 

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

50 """Exercise information parsing result.""" 

51 

52 exercise_duration_seconds: int | None 

53 exercise_intensity_percent: int | None 

54 offset: int 

55 

56 

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

58 """Medication information parsing result.""" 

59 

60 medication_id: MedicationType | None 

61 medication_kg: float | None 

62 offset: int 

63 

64 

65class GlucoseMeasurementContextBits: 

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

67 

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

69 

70 TESTER_START_BIT = 4 # Tester value starts at bit 4 

71 TESTER_BIT_WIDTH = 4 # Tester value uses 4 bits 

72 HEALTH_START_BIT = 0 # Health value starts at bit 0 

73 HEALTH_BIT_WIDTH = 4 # Health value uses 4 bits 

74 

75 

76class CarbohydrateType(IntEnum): 

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

78 

79 BREAKFAST = 1 

80 LUNCH = 2 

81 DINNER = 3 

82 SNACK = 4 

83 DRINK = 5 

84 SUPPER = 6 

85 BRUNCH = 7 

86 

87 def __str__(self) -> str: 

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

89 names = { 

90 self.BREAKFAST: "Breakfast", 

91 self.LUNCH: "Lunch", 

92 self.DINNER: "Dinner", 

93 self.SNACK: "Snack", 

94 self.DRINK: "Drink", 

95 self.SUPPER: "Supper", 

96 self.BRUNCH: "Brunch", 

97 } 

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

99 

100 

101class MealType(IntEnum): 

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

103 

104 PREPRANDIAL = 1 

105 POSTPRANDIAL = 2 

106 FASTING = 3 

107 CASUAL = 4 

108 BEDTIME = 5 

109 

110 def __str__(self) -> str: 

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

112 names = { 

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

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

115 self.FASTING: "Fasting", 

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

117 self.BEDTIME: "Bedtime", 

118 } 

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

120 

121 

122class GlucoseTester(IntEnum): 

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

124 

125 SELF = 1 

126 HEALTH_CARE_PROFESSIONAL = 2 

127 LAB_TEST = 3 

128 NOT_AVAILABLE = 15 

129 

130 def __str__(self) -> str: 

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

132 names = { 

133 self.SELF: "Self", 

134 self.HEALTH_CARE_PROFESSIONAL: "Health Care Professional", 

135 self.LAB_TEST: "Lab test", 

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

137 } 

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

139 

140 

141class HealthType(IntEnum): 

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

143 

144 MINOR_HEALTH_ISSUES = 1 

145 MAJOR_HEALTH_ISSUES = 2 

146 DURING_MENSES = 3 

147 UNDER_STRESS = 4 

148 NO_HEALTH_ISSUES = 5 

149 NOT_AVAILABLE = 15 

150 

151 def __str__(self) -> str: 

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

153 names = { 

154 self.MINOR_HEALTH_ISSUES: "Minor health issues", 

155 self.MAJOR_HEALTH_ISSUES: "Major health issues", 

156 self.DURING_MENSES: "During menses", 

157 self.UNDER_STRESS: "Under stress", 

158 self.NO_HEALTH_ISSUES: "No health issues", 

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

160 } 

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

162 

163 

164class MedicationType(IntEnum): 

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

166 

167 RAPID_ACTING_INSULIN = 1 

168 SHORT_ACTING_INSULIN = 2 

169 INTERMEDIATE_ACTING_INSULIN = 3 

170 LONG_ACTING_INSULIN = 4 

171 PRE_MIXED_INSULIN = 5 

172 

173 def __str__(self) -> str: 

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

175 names = { 

176 self.RAPID_ACTING_INSULIN: "Rapid acting insulin", 

177 self.SHORT_ACTING_INSULIN: "Short acting insulin", 

178 self.INTERMEDIATE_ACTING_INSULIN: "Intermediate acting insulin", 

179 self.LONG_ACTING_INSULIN: "Long acting insulin", 

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

181 } 

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

183 

184 

185class GlucoseMeasurementContextExtendedFlags(IntEnum): 

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

187 

188 Currently all bits are reserved for future use. 

189 """ 

190 

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

192 

193 RESERVED_BIT_0 = 0x01 

194 RESERVED_BIT_1 = 0x02 

195 RESERVED_BIT_2 = 0x04 

196 RESERVED_BIT_3 = 0x08 

197 RESERVED_BIT_4 = 0x10 

198 RESERVED_BIT_5 = 0x20 

199 RESERVED_BIT_6 = 0x40 

200 RESERVED_BIT_7 = 0x80 

201 

202 @staticmethod 

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

204 """Get description of extended flags. 

205 

206 Args: 

207 flags: Extended flags value (0-255) 

208 

209 Returns: 

210 Description string indicating all bits are reserved 

211 

212 """ 

213 if flags == 0: 

214 return "No extended flags set" 

215 

216 # All bits are currently reserved for future use 

217 bit_descriptions: list[str] = [] 

218 for bit in range(8): 

219 bit_value = 1 << bit 

220 if flags & bit_value: 

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

222 

223 return "; ".join(bit_descriptions) 

224 

225 

226class GlucoseMeasurementContextFlags(IntFlag): 

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

228 

229 EXTENDED_FLAGS_PRESENT = 0x01 

230 CARBOHYDRATE_PRESENT = 0x02 

231 MEAL_PRESENT = 0x04 

232 TESTER_HEALTH_PRESENT = 0x08 

233 EXERCISE_PRESENT = 0x10 

234 MEDICATION_PRESENT = 0x20 

235 HBA1C_PRESENT = 0x40 

236 RESERVED = 0x80 

237 

238 

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

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

241 

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

243 """ 

244 

245 sequence_number: int 

246 flags: GlucoseMeasurementContextFlags 

247 # Optional fields - will be set by parsing methods 

248 extended_flags: int | None = None 

249 carbohydrate_id: CarbohydrateType | None = None 

250 carbohydrate_kg: float | None = None 

251 meal: MealType | None = None 

252 tester: GlucoseTester | None = None 

253 health: HealthType | None = None 

254 exercise_duration_seconds: int | None = None 

255 exercise_intensity_percent: int | None = None 

256 medication_id: MedicationType | None = None 

257 medication_kg: float | None = None 

258 hba1c_percent: float | None = None 

259 

260 def __post_init__(self) -> None: 

261 """Validate glucose measurement context data.""" 

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

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

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

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

266 

267 

268class GlucoseMeasurementContextCharacteristic(BaseCharacteristic[GlucoseMeasurementContextData]): 

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

270 

271 Used to transmit additional context for glucose measurements 

272 including carbohydrate intake, exercise, medication, and HbA1c 

273 information. 

274 

275 SIG Specification Pattern: 

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

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

278 from a corresponding Glucose Measurement characteristic. 

279 """ 

280 

281 _characteristic_name: str = "Glucose Measurement Context" 

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

283 

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

285 _required_dependencies = [GlucoseMeasurementCharacteristic] 

286 

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

288 max_length: int | None = ( 

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

290 ) 

291 allow_variable_length: bool = True # Variable optional fields 

292 

293 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> GlucoseMeasurementContextData: # pylint: disable=too-many-locals 

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

295 

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

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

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

299 

300 Args: 

301 data: Raw bytearray from BLE characteristic. 

302 ctx: Optional context providing access to Glucose Measurement characteristic 

303 for sequence number validation. 

304 

305 Returns: 

306 GlucoseMeasurementContextData containing parsed glucose context data. 

307 

308 Raises: 

309 ValueError: If data format is invalid. 

310 

311 SIG Pattern: 

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

313 a Glucose Measurement sequence_number, following the SIG specification pattern 

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

315 

316 """ 

317 if len(data) < 3: 

318 raise ValueError("Glucose Measurement Context data must be at least 3 bytes") 

319 

320 flags_raw = data[0] 

321 flags = GlucoseMeasurementContextFlags(flags_raw) 

322 offset = 1 

323 

324 # Parse sequence number (2 bytes) 

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

326 offset += 2 

327 

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

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

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

331 glucose_meas = self.get_context_characteristic(ctx, GlucoseMeasurementCharacteristic) 

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

333 # Extract sequence number from GlucoseMeasurementData 

334 meas_seq = glucose_meas.sequence_number 

335 if meas_seq != sequence_number: 

336 logger.warning( 

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

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

339 sequence_number, 

340 meas_seq, 

341 ) 

342 

343 # Parse all optional fields based on flags 

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

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

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

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

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

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

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

351 

352 # Create struct with all parsed values 

353 return GlucoseMeasurementContextData( 

354 sequence_number=sequence_number, 

355 flags=flags, 

356 extended_flags=extended.extended_flags, 

357 carbohydrate_id=carb.carbohydrate_id, 

358 carbohydrate_kg=carb.carbohydrate_kg, 

359 meal=meal_result.meal, 

360 tester=tester_health.tester, 

361 health=tester_health.health, 

362 exercise_duration_seconds=exercise.exercise_duration_seconds, 

363 exercise_intensity_percent=exercise.exercise_intensity_percent, 

364 medication_id=medication.medication_id, 

365 medication_kg=medication.medication_kg, 

366 hba1c_percent=hba1c_percent, 

367 ) 

368 

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

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

371 

372 Args: 

373 data: GlucoseMeasurementContextData containing glucose measurement context data 

374 

375 Returns: 

376 Encoded bytes representing the measurement context 

377 

378 """ 

379 sequence_number = data.sequence_number 

380 if not 0 <= sequence_number <= 0xFFFF: 

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

382 

383 # Use the flags from the data structure 

384 flags = data.flags 

385 

386 result = bytearray([flags]) 

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

388 

389 # Encode optional extended flags 

390 if data.extended_flags is not None: 

391 result.append(data.extended_flags) 

392 

393 # Encode optional carbohydrate information 

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

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

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

397 

398 # Encode optional meal information 

399 if data.meal is not None: 

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

401 

402 # Encode optional tester/health information 

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

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

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

406 ) 

407 result.append(tester_health) 

408 

409 # Encode optional exercise information 

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

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

412 result.append(data.exercise_intensity_percent) 

413 

414 # Encode optional medication information 

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

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

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

418 

419 # Encode optional HbA1c information 

420 if data.hba1c_percent is not None: 

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

422 

423 return result 

424 

425 def _parse_extended_flags( 

426 self, 

427 data: bytearray, 

428 flags: GlucoseMeasurementContextFlags, 

429 offset: int, 

430 ) -> ExtendedFlagsResult: 

431 """Parse optional extended flags field.""" 

432 extended_flags: int | None = None 

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

434 extended_flags = int(data[offset]) 

435 offset += 1 

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

437 

438 def _parse_carbohydrate_info( 

439 self, 

440 data: bytearray, 

441 flags: GlucoseMeasurementContextFlags, 

442 offset: int, 

443 ) -> CarbohydrateResult: 

444 """Parse optional carbohydrate information field.""" 

445 carbohydrate_id: CarbohydrateType | None = None 

446 carbohydrate_kg: float | None = None 

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

448 carb_id = data[offset] 

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

450 carbohydrate_id = CarbohydrateType(carb_id) 

451 carbohydrate_kg = carb_value 

452 offset += 3 

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

454 

455 def _parse_meal_info( 

456 self, 

457 data: bytearray, 

458 flags: GlucoseMeasurementContextFlags, 

459 offset: int, 

460 ) -> MealResult: 

461 """Parse optional meal information field.""" 

462 meal: MealType | None = None 

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

464 meal = MealType(data[offset]) 

465 offset += 1 

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

467 

468 def _parse_tester_health_info( 

469 self, 

470 data: bytearray, 

471 flags: GlucoseMeasurementContextFlags, 

472 offset: int, 

473 ) -> TesterHealthResult: 

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

475 tester: GlucoseTester | None = None 

476 health: HealthType | None = None 

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

478 tester_health = data[offset] 

479 tester_raw = BitFieldUtils.extract_bit_field( 

480 tester_health, 

481 GlucoseMeasurementContextBits.TESTER_START_BIT, 

482 GlucoseMeasurementContextBits.TESTER_BIT_WIDTH, 

483 ) # Bits 4-7 (4 bits) 

484 health_raw = BitFieldUtils.extract_bit_field( 

485 tester_health, 

486 GlucoseMeasurementContextBits.HEALTH_START_BIT, 

487 GlucoseMeasurementContextBits.HEALTH_BIT_WIDTH, 

488 ) # Bits 0-3 (4 bits) 

489 tester = GlucoseTester(tester_raw) 

490 health = HealthType(health_raw) 

491 offset += 1 

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

493 

494 def _parse_exercise_info( 

495 self, 

496 data: bytearray, 

497 flags: GlucoseMeasurementContextFlags, 

498 offset: int, 

499 ) -> ExerciseResult: 

500 """Parse optional exercise information field.""" 

501 exercise_duration_seconds: int | None = None 

502 exercise_intensity_percent: int | None = None 

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

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

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

506 offset += 3 

507 return ExerciseResult( 

508 exercise_duration_seconds=exercise_duration_seconds, 

509 exercise_intensity_percent=exercise_intensity_percent, 

510 offset=offset, 

511 ) 

512 

513 def _parse_medication_info( 

514 self, 

515 data: bytearray, 

516 flags: GlucoseMeasurementContextFlags, 

517 offset: int, 

518 ) -> MedicationResult: 

519 """Parse optional medication information field.""" 

520 medication_id: MedicationType | None = None 

521 medication_kg: float | None = None 

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

523 medication_id = MedicationType(data[offset]) 

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

525 offset += 3 

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

527 

528 def _parse_hba1c_info( 

529 self, 

530 data: bytearray, 

531 flags: GlucoseMeasurementContextFlags, 

532 offset: int, 

533 ) -> float | None: 

534 """Parse optional HbA1c information field. 

535 

536 Returns: 

537 HbA1c percentage or None 

538 

539 """ 

540 hba1c_percent: float | None = None 

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

542 hba1c_percent = IEEE11073Parser.parse_sfloat(data, offset) 

543 return hba1c_percent