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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +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[GlucoseMeasurementContextData]):
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 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 )
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)
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 )
369 def _encode_value(self, data: GlucoseMeasurementContextData) -> bytearray:
370 """Encode glucose measurement context value back to bytes.
372 Args:
373 data: GlucoseMeasurementContextData containing glucose measurement context data
375 Returns:
376 Encoded bytes representing the measurement context
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")
383 # Use the flags from the data structure
384 flags = data.flags
386 result = bytearray([flags])
387 result.extend(DataParser.encode_int16(sequence_number, signed=False))
389 # Encode optional extended flags
390 if data.extended_flags is not None:
391 result.append(data.extended_flags)
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))
398 # Encode optional meal information
399 if data.meal is not None:
400 result.append(int(data.meal))
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)
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)
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))
419 # Encode optional HbA1c information
420 if data.hba1c_percent is not None:
421 result.extend(IEEE11073Parser.encode_sfloat(data.hba1c_percent))
423 return result
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)
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)
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)
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)
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 )
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)
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.
536 Returns:
537 HbA1c percentage or None
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