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

241 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +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): 

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 glucose_meas.parse_success: 

333 # Extract sequence number from GlucoseMeasurementData 

334 if hasattr(glucose_meas.value, "sequence_number"): 

335 meas_seq = glucose_meas.value.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 <= 0xFFFF: 

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