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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
1"""Glucose Measurement Context characteristic implementation."""
3from __future__ import annotations
5import logging
6from enum import IntEnum, IntFlag
8import msgspec
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
16logger = logging.getLogger(__name__)
19class ExtendedFlagsResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
20 """Extended flags parsing result."""
22 extended_flags: int | None
23 offset: int
26class CarbohydrateResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
27 """Carbohydrate information parsing result."""
29 carbohydrate_id: CarbohydrateType | None
30 carbohydrate_kg: float | None
31 offset: int
34class MealResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
35 """Meal information parsing result."""
37 meal: MealType | None
38 offset: int
41class TesterHealthResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
42 """Tester and health information parsing result."""
44 tester: GlucoseTester | None
45 health: HealthType | None
46 offset: int
49class ExerciseResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
50 """Exercise information parsing result."""
52 exercise_duration_seconds: int | None
53 exercise_intensity_percent: int | None
54 offset: int
57class MedicationResult(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
58 """Medication information parsing result."""
60 medication_id: MedicationType | None
61 medication_kg: float | None
62 offset: int
65class GlucoseMeasurementContextBits:
66 """Glucose Measurement Context bit field constants."""
68 # pylint: disable=too-few-public-methods
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
76class CarbohydrateType(IntEnum):
77 """Carbohydrate type enumeration as per Bluetooth SIG specification."""
79 BREAKFAST = 1
80 LUNCH = 2
81 DINNER = 3
82 SNACK = 4
83 DRINK = 5
84 SUPPER = 6
85 BRUNCH = 7
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")
101class MealType(IntEnum):
102 """Meal type enumeration as per Bluetooth SIG specification."""
104 PREPRANDIAL = 1
105 POSTPRANDIAL = 2
106 FASTING = 3
107 CASUAL = 4
108 BEDTIME = 5
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")
122class GlucoseTester(IntEnum):
123 """Glucose tester type enumeration as per Bluetooth SIG specification."""
125 SELF = 1
126 HEALTH_CARE_PROFESSIONAL = 2
127 LAB_TEST = 3
128 NOT_AVAILABLE = 15
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")
141class HealthType(IntEnum):
142 """Health type enumeration as per Bluetooth SIG specification."""
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
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")
164class MedicationType(IntEnum):
165 """Medication type enumeration as per Bluetooth SIG specification."""
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
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")
185class GlucoseMeasurementContextExtendedFlags(IntEnum):
186 """Glucose Measurement Context Extended Flags constants as per Bluetooth SIG specification.
188 Currently all bits are reserved for future use.
189 """
191 # pylint: disable=too-few-public-methods
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
202 @staticmethod
203 def get_description(flags: int) -> str:
204 """Get description of extended flags.
206 Args:
207 flags: Extended flags value (0-255)
209 Returns:
210 Description string indicating all bits are reserved
212 """
213 if flags == 0:
214 return "No extended flags set"
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)")
223 return "; ".join(bit_descriptions)
226class GlucoseMeasurementContextFlags(IntFlag):
227 """Glucose Measurement Context flags as per Bluetooth SIG specification."""
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
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.
242 Used for both parsing and encoding - None values represent optional fields.
243 """
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
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)")
268class GlucoseMeasurementContextCharacteristic(BaseCharacteristic):
269 """Glucose Measurement Context characteristic (0x2A34).
271 Used to transmit additional context for glucose measurements
272 including carbohydrate intake, exercise, medication, and HbA1c
273 information.
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 """
281 _characteristic_name: str = "Glucose Measurement Context"
282 _manual_unit: str = "various" # Multiple units in context data
284 # Declare dependency on Glucose Measurement for sequence number matching (REQUIRED)
285 _required_dependencies = [GlucoseMeasurementCharacteristic]
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
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.
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)].
300 Args:
301 data: Raw bytearray from BLE characteristic.
302 ctx: Optional context providing access to Glucose Measurement characteristic
303 for sequence number validation.
305 Returns:
306 GlucoseMeasurementContextData containing parsed glucose context data.
308 Raises:
309 ValueError: If data format is invalid.
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.
316 """
317 if len(data) < 3:
318 raise ValueError("Glucose Measurement Context data must be at least 3 bytes")
320 flags_raw = data[0]
321 flags = GlucoseMeasurementContextFlags(flags_raw)
322 offset = 1
324 # Parse sequence number (2 bytes)
325 sequence_number = DataParser.parse_int16(data, offset, signed=False)
326 offset += 2
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 )
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 <= 0xFFFF:
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