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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Glucose Measurement Context characteristic implementation."""
3from __future__ import annotations
5import logging
6from enum import IntEnum, IntFlag
7from typing import Any, ClassVar
9import msgspec
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
17logger = logging.getLogger(__name__)
20class ExtendedFlagsResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
21 """Extended flags parsing result."""
23 extended_flags: int | None
24 offset: int
27class CarbohydrateResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
28 """Carbohydrate information parsing result."""
30 carbohydrate_id: CarbohydrateType | None
31 carbohydrate_kg: float | None
32 offset: int
35class MealResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
36 """Meal information parsing result."""
38 meal: MealType | None
39 offset: int
42class TesterHealthResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
43 """Tester and health information parsing result."""
45 tester: GlucoseTester | None
46 health: HealthType | None
47 offset: int
50class ExerciseResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
51 """Exercise information parsing result."""
53 exercise_duration_seconds: int | None
54 exercise_intensity_percent: int | None
55 offset: int
58class MedicationResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
59 """Medication information parsing result."""
61 medication_id: MedicationType | None
62 medication_kg: float | None
63 offset: int
66class GlucoseMeasurementContextBits:
67 """Glucose Measurement Context bit field constants."""
69 # pylint: disable=too-few-public-methods
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
77class CarbohydrateType(IntEnum):
78 """Carbohydrate type enumeration as per Bluetooth SIG specification."""
80 BREAKFAST = 1
81 LUNCH = 2
82 DINNER = 3
83 SNACK = 4
84 DRINK = 5
85 SUPPER = 6
86 BRUNCH = 7
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")
102class MealType(IntEnum):
103 """Meal type enumeration as per Bluetooth SIG specification."""
105 PREPRANDIAL = 1
106 POSTPRANDIAL = 2
107 FASTING = 3
108 CASUAL = 4
109 BEDTIME = 5
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")
123class GlucoseTester(IntEnum):
124 """Glucose tester type enumeration as per Bluetooth SIG specification."""
126 SELF = 1
127 HEALTH_CARE_PROFESSIONAL = 2
128 LAB_TEST = 3
129 NOT_AVAILABLE = 15
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")
142class HealthType(IntEnum):
143 """Health type enumeration as per Bluetooth SIG specification."""
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
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")
165class MedicationType(IntEnum):
166 """Medication type enumeration as per Bluetooth SIG specification."""
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
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")
186class GlucoseMeasurementContextExtendedFlags(IntEnum):
187 """Glucose Measurement Context Extended Flags constants as per Bluetooth SIG specification.
189 Currently all bits are reserved for future use.
190 """
192 # pylint: disable=too-few-public-methods
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
203 @staticmethod
204 def get_description(flags: int) -> str:
205 """Get description of extended flags.
207 Args:
208 flags: Extended flags value (0-255)
210 Returns:
211 Description string indicating all bits are reserved
213 """
214 if flags == 0:
215 return "No extended flags set"
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)")
224 return "; ".join(bit_descriptions)
227class GlucoseMeasurementContextFlags(IntFlag):
228 """Glucose Measurement Context flags as per Bluetooth SIG specification."""
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
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.
243 Used for both parsing and encoding - None values represent optional fields.
244 """
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
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)")
269class GlucoseMeasurementContextCharacteristic(BaseCharacteristic[GlucoseMeasurementContextData]):
270 """Glucose Measurement Context characteristic (0x2A34).
272 Used to transmit additional context for glucose measurements
273 including carbohydrate intake, exercise, medication, and HbA1c
274 information.
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 """
282 _characteristic_name: str = "Glucose Measurement Context"
283 _manual_unit: str = "various" # Multiple units in context data
285 # Declare dependency on Glucose Measurement for sequence number matching (REQUIRED)
286 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [GlucoseMeasurementCharacteristic]
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
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.
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)].
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.
309 Returns:
310 GlucoseMeasurementContextData containing parsed glucose context data.
312 Raises:
313 ValueError: If data format is invalid.
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.
320 """
321 flags_raw = data[0]
322 flags = GlucoseMeasurementContextFlags(flags_raw)
323 offset = 1
325 # Parse sequence number (2 bytes)
326 sequence_number = DataParser.parse_int16(data, offset, signed=False)
327 offset += 2
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 )
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)
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 )
370 def _encode_value(self, data: GlucoseMeasurementContextData) -> bytearray:
371 """Encode glucose measurement context value back to bytes.
373 Args:
374 data: GlucoseMeasurementContextData containing glucose measurement context data
376 Returns:
377 Encoded bytes representing the measurement context
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")
384 # Use the flags from the data structure
385 flags = data.flags
387 result = bytearray([flags])
388 result.extend(DataParser.encode_int16(sequence_number, signed=False))
390 # Encode optional extended flags
391 if data.extended_flags is not None:
392 result.append(data.extended_flags)
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))
399 # Encode optional meal information
400 if data.meal is not None:
401 result.append(int(data.meal))
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)
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)
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))
420 # Encode optional HbA1c information
421 if data.hba1c_percent is not None:
422 result.extend(IEEE11073Parser.encode_sfloat(data.hba1c_percent))
424 return result
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)
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)
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)
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)
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 )
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)
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.
537 Returns:
538 HbA1c percentage or None
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