Coverage for src / bluetooth_sig / gatt / characteristics / glucose_measurement_context.py: 87%
243 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +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_value: float | None
63 medication_unit: str | None
64 offset: int
67class GlucoseMeasurementContextBits:
68 """Glucose Measurement Context bit field constants."""
70 # pylint: disable=too-few-public-methods
72 TESTER_START_BIT = 0 # Tester value starts at bit 0 (LOW nibble)
73 TESTER_BIT_WIDTH = 4 # Tester value uses 4 bits
74 HEALTH_START_BIT = 4 # Health value starts at bit 4 (HIGH nibble)
75 HEALTH_BIT_WIDTH = 4 # Health value uses 4 bits
78class CarbohydrateType(IntEnum):
79 """Carbohydrate type enumeration as per Bluetooth SIG specification."""
81 BREAKFAST = 1
82 LUNCH = 2
83 DINNER = 3
84 SNACK = 4
85 DRINK = 5
86 SUPPER = 6
87 BRUNCH = 7
89 def __str__(self) -> str:
90 """Return human-readable carbohydrate type name."""
91 names = {
92 self.BREAKFAST: "Breakfast",
93 self.LUNCH: "Lunch",
94 self.DINNER: "Dinner",
95 self.SNACK: "Snack",
96 self.DRINK: "Drink",
97 self.SUPPER: "Supper",
98 self.BRUNCH: "Brunch",
99 }
100 return names.get(self, "Reserved for Future Use")
103class MealType(IntEnum):
104 """Meal type enumeration as per Bluetooth SIG specification."""
106 PREPRANDIAL = 1
107 POSTPRANDIAL = 2
108 FASTING = 3
109 CASUAL = 4
110 BEDTIME = 5
112 def __str__(self) -> str:
113 """Return human-readable meal type name."""
114 names = {
115 self.PREPRANDIAL: "Preprandial (before meal)",
116 self.POSTPRANDIAL: "Postprandial (after meal)",
117 self.FASTING: "Fasting",
118 self.CASUAL: "Casual (snacks, drinks, etc.)",
119 self.BEDTIME: "Bedtime",
120 }
121 return names.get(self, "Reserved for Future Use")
124class GlucoseTester(IntEnum):
125 """Glucose tester type enumeration as per Bluetooth SIG specification."""
127 SELF = 1
128 HEALTH_CARE_PROFESSIONAL = 2
129 LAB_TEST = 3
130 NOT_AVAILABLE = 15
132 def __str__(self) -> str:
133 """Return human-readable tester type name."""
134 names = {
135 self.SELF: "Self",
136 self.HEALTH_CARE_PROFESSIONAL: "Health Care Professional",
137 self.LAB_TEST: "Lab test",
138 self.NOT_AVAILABLE: "Tester value not available",
139 }
140 return names.get(self, "Reserved for Future Use")
143class HealthType(IntEnum):
144 """Health type enumeration as per Bluetooth SIG specification."""
146 MINOR_HEALTH_ISSUES = 1
147 MAJOR_HEALTH_ISSUES = 2
148 DURING_MENSES = 3
149 UNDER_STRESS = 4
150 NO_HEALTH_ISSUES = 5
151 NOT_AVAILABLE = 15
153 def __str__(self) -> str:
154 """Return human-readable health type name."""
155 names = {
156 self.MINOR_HEALTH_ISSUES: "Minor health issues",
157 self.MAJOR_HEALTH_ISSUES: "Major health issues",
158 self.DURING_MENSES: "During menses",
159 self.UNDER_STRESS: "Under stress",
160 self.NO_HEALTH_ISSUES: "No health issues",
161 self.NOT_AVAILABLE: "Health value not available",
162 }
163 return names.get(self, "Reserved for Future Use")
166class MedicationType(IntEnum):
167 """Medication type enumeration as per Bluetooth SIG specification."""
169 RAPID_ACTING_INSULIN = 1
170 SHORT_ACTING_INSULIN = 2
171 INTERMEDIATE_ACTING_INSULIN = 3
172 LONG_ACTING_INSULIN = 4
173 PRE_MIXED_INSULIN = 5
175 def __str__(self) -> str:
176 """Return human-readable medication type name."""
177 names = {
178 self.RAPID_ACTING_INSULIN: "Rapid acting insulin",
179 self.SHORT_ACTING_INSULIN: "Short acting insulin",
180 self.INTERMEDIATE_ACTING_INSULIN: "Intermediate acting insulin",
181 self.LONG_ACTING_INSULIN: "Long acting insulin",
182 self.PRE_MIXED_INSULIN: "Pre-mixed insulin",
183 }
184 return names.get(self, "Reserved for Future Use")
187class GlucoseMeasurementContextExtendedFlags(IntEnum):
188 """Glucose Measurement Context Extended Flags constants as per Bluetooth SIG specification.
190 Currently all bits are reserved for future use.
191 """
193 # pylint: disable=too-few-public-methods
195 RESERVED_BIT_0 = 0x01
196 RESERVED_BIT_1 = 0x02
197 RESERVED_BIT_2 = 0x04
198 RESERVED_BIT_3 = 0x08
199 RESERVED_BIT_4 = 0x10
200 RESERVED_BIT_5 = 0x20
201 RESERVED_BIT_6 = 0x40
202 RESERVED_BIT_7 = 0x80
204 @staticmethod
205 def get_description(flags: int) -> str:
206 """Get description of extended flags.
208 Args:
209 flags: Extended flags value (0-255)
211 Returns:
212 Description string indicating all bits are reserved
214 """
215 if flags == 0:
216 return "No extended flags set"
218 # All bits are currently reserved for future use
219 bit_descriptions: list[str] = []
220 for bit in range(8):
221 bit_value = 1 << bit
222 if flags & bit_value:
223 bit_descriptions.append(f"Bit {bit} (Reserved for Future Use)")
225 return "; ".join(bit_descriptions)
228class GlucoseMeasurementContextFlags(IntFlag):
229 """Glucose Measurement Context flags as per Bluetooth SIG specification."""
231 CARBOHYDRATE_PRESENT = 0x01 # Bit 0
232 MEAL_PRESENT = 0x02 # Bit 1
233 TESTER_HEALTH_PRESENT = 0x04 # Bit 2
234 EXERCISE_PRESENT = 0x08 # Bit 3
235 MEDICATION_PRESENT = 0x10 # Bit 4
236 MEDICATION_UNITS = 0x20 # Bit 5: 0=kg, 1=litres
237 HBA1C_PRESENT = 0x40 # Bit 6
238 EXTENDED_FLAGS_PRESENT = 0x80 # Bit 7
241class GlucoseMeasurementContextData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
242 """Parsed data from Glucose Measurement Context characteristic.
244 Used for both parsing and encoding - None values represent optional fields.
245 """
247 sequence_number: int
248 flags: GlucoseMeasurementContextFlags
249 # Optional fields - will be set by parsing methods
250 extended_flags: int | None = None
251 carbohydrate_id: CarbohydrateType | None = None
252 carbohydrate_kg: float | None = None
253 meal: MealType | None = None
254 tester: GlucoseTester | None = None
255 health: HealthType | None = None
256 exercise_duration_seconds: int | None = None
257 exercise_intensity_percent: int | None = None
258 medication_id: MedicationType | None = None
259 medication_value: float | None = None
260 medication_unit: str | None = None
261 hba1c_percent: float | None = None
263 def __post_init__(self) -> None:
264 """Validate glucose measurement context data."""
265 if not 0 <= self.flags <= UINT8_MAX:
266 raise ValueError("Flags must be a uint8 value (0-UINT8_MAX)")
267 if not 0 <= self.sequence_number <= UINT16_MAX:
268 raise ValueError("Sequence number must be a uint16 value (0-UINT16_MAX)")
271class GlucoseMeasurementContextCharacteristic(BaseCharacteristic[GlucoseMeasurementContextData]):
272 """Glucose Measurement Context characteristic (0x2A34).
274 Used to transmit additional context for glucose measurements
275 including carbohydrate intake, exercise, medication, and HbA1c
276 information.
278 SIG Specification Pattern:
279 This characteristic depends on Glucose Measurement (0x2A18) for sequence number
280 matching. The sequence_number field in this context must match the sequence_number
281 from a corresponding Glucose Measurement characteristic.
282 """
284 _characteristic_name: str = "Glucose Measurement Context"
285 _manual_unit: str = "various" # Multiple units in context data
287 # Declare dependency on Glucose Measurement for sequence number matching (REQUIRED)
288 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [GlucoseMeasurementCharacteristic]
290 min_length: int | None = 3 # Flags(1) + Sequence(2) minimum
291 max_length: int | None = (
292 19 # + ExtendedFlags(1) + Carb(3) + Meal(1) + TesterHealth(1) + Exercise(3) + Medication(3) + HbA1c(2) maximum
293 )
294 allow_variable_length: bool = True # Variable optional fields
296 def _decode_value(
297 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True
298 ) -> GlucoseMeasurementContextData: # pylint: disable=too-many-locals # Complex spec with many optional context fields
299 """Parse glucose measurement context data according to Bluetooth specification.
301 Format: Flags(1) + Sequence Number(2) + [Extended Flags(1)] + [Carbohydrate ID(1) + Carb(2)] +
302 [Meal(1)] + [Tester-Health(1)] + [Exercise Duration(2) + Exercise Intensity(1)] +
303 [Medication ID(1) + Medication(2)] + [HbA1c(2)].
305 Args:
306 data: Raw bytearray from BLE characteristic.
307 ctx: Optional context providing access to Glucose Measurement characteristic
308 validate: Whether to validate ranges (default True)
309 for sequence number validation.
311 Returns:
312 GlucoseMeasurementContextData containing parsed glucose context data.
314 Raises:
315 ValueError: If data format is invalid.
317 SIG Pattern:
318 When context is available, validates that this context's sequence_number matches
319 a Glucose Measurement sequence_number, following the SIG specification pattern
320 where contexts are paired with measurements via sequence number matching.
322 """
323 flags_raw = data[0]
324 flags = GlucoseMeasurementContextFlags(flags_raw)
325 offset = 1
327 # Parse sequence number (2 bytes)
328 sequence_number = DataParser.parse_int16(data, offset, signed=False)
329 offset += 2
331 # Validate sequence number matching with Glucose Measurement if context available
332 # SIG Specification: "Contains the sequence number of the corresponding Glucose Measurement"
333 if ctx is not None and isinstance(ctx, CharacteristicContext):
334 glucose_meas = self.get_context_characteristic(ctx, GlucoseMeasurementCharacteristic)
335 if glucose_meas and hasattr(glucose_meas, "sequence_number"):
336 # Extract sequence number from GlucoseMeasurementData
337 meas_seq = glucose_meas.sequence_number
338 if meas_seq != sequence_number:
339 logger.warning(
340 "Glucose Measurement Context sequence number (%d) does not match "
341 "Glucose Measurement sequence number (%d)",
342 sequence_number,
343 meas_seq,
344 )
346 # Parse all optional fields based on flags
347 extended = self._parse_extended_flags(data, flags, offset)
348 carb = self._parse_carbohydrate_info(data, flags, extended.offset)
349 meal_result = self._parse_meal_info(data, flags, carb.offset)
350 tester_health = self._parse_tester_health_info(data, flags, meal_result.offset)
351 exercise = self._parse_exercise_info(data, flags, tester_health.offset)
352 medication = self._parse_medication_info(data, flags, exercise.offset)
353 hba1c_percent = self._parse_hba1c_info(data, flags, medication.offset)
355 # Create struct with all parsed values
356 return GlucoseMeasurementContextData(
357 sequence_number=sequence_number,
358 flags=flags,
359 extended_flags=extended.extended_flags,
360 carbohydrate_id=carb.carbohydrate_id,
361 carbohydrate_kg=carb.carbohydrate_kg,
362 meal=meal_result.meal,
363 tester=tester_health.tester,
364 health=tester_health.health,
365 exercise_duration_seconds=exercise.exercise_duration_seconds,
366 exercise_intensity_percent=exercise.exercise_intensity_percent,
367 medication_id=medication.medication_id,
368 medication_value=medication.medication_value,
369 medication_unit=medication.medication_unit,
370 hba1c_percent=hba1c_percent,
371 )
373 def _encode_value(self, data: GlucoseMeasurementContextData) -> bytearray:
374 """Encode glucose measurement context value back to bytes.
376 Args:
377 data: GlucoseMeasurementContextData containing glucose measurement context data
379 Returns:
380 Encoded bytes representing the measurement context
382 """
383 sequence_number = data.sequence_number
384 if not 0 <= sequence_number <= UINT16_MAX:
385 raise ValueError(f"Sequence number {sequence_number} exceeds uint16 range")
387 # Use the flags from the data structure
388 flags = data.flags
390 result = bytearray([flags])
391 result.extend(DataParser.encode_int16(sequence_number, signed=False))
393 # Encode optional extended flags
394 if data.extended_flags is not None:
395 result.append(data.extended_flags)
397 # Encode optional carbohydrate information
398 if data.carbohydrate_id is not None and data.carbohydrate_kg is not None:
399 result.append(int(data.carbohydrate_id))
400 result.extend(IEEE11073Parser.encode_sfloat(data.carbohydrate_kg))
402 # Encode optional meal information
403 if data.meal is not None:
404 result.append(int(data.meal))
406 # Encode optional tester/health information
407 if data.tester is not None and data.health is not None:
408 tester_health = (int(data.tester) << GlucoseMeasurementContextBits.TESTER_START_BIT) | (
409 int(data.health) << GlucoseMeasurementContextBits.HEALTH_START_BIT
410 )
411 result.append(tester_health)
413 # Encode optional exercise information
414 if data.exercise_duration_seconds is not None and data.exercise_intensity_percent is not None:
415 result.extend(DataParser.encode_int16(data.exercise_duration_seconds, signed=False))
416 result.append(data.exercise_intensity_percent)
418 # Encode optional medication information
419 if data.medication_id is not None and data.medication_value is not None:
420 result.append(int(data.medication_id))
421 result.extend(IEEE11073Parser.encode_sfloat(data.medication_value))
423 # Encode optional HbA1c information
424 if data.hba1c_percent is not None:
425 result.extend(IEEE11073Parser.encode_sfloat(data.hba1c_percent))
427 return result
429 def _parse_extended_flags(
430 self,
431 data: bytearray,
432 flags: GlucoseMeasurementContextFlags,
433 offset: int,
434 ) -> ExtendedFlagsResult:
435 """Parse optional extended flags field."""
436 extended_flags: int | None = None
437 if GlucoseMeasurementContextFlags.EXTENDED_FLAGS_PRESENT in flags and len(data) >= offset + 1:
438 extended_flags = int(data[offset])
439 offset += 1
440 return ExtendedFlagsResult(extended_flags=extended_flags, offset=offset)
442 def _parse_carbohydrate_info(
443 self,
444 data: bytearray,
445 flags: GlucoseMeasurementContextFlags,
446 offset: int,
447 ) -> CarbohydrateResult:
448 """Parse optional carbohydrate information field."""
449 carbohydrate_id: CarbohydrateType | None = None
450 carbohydrate_kg: float | None = None
451 if GlucoseMeasurementContextFlags.CARBOHYDRATE_PRESENT in flags and len(data) >= offset + 3:
452 carb_id = data[offset]
453 carb_value = IEEE11073Parser.parse_sfloat(data, offset + 1)
454 carbohydrate_id = CarbohydrateType(carb_id)
455 carbohydrate_kg = carb_value
456 offset += 3
457 return CarbohydrateResult(carbohydrate_id=carbohydrate_id, carbohydrate_kg=carbohydrate_kg, offset=offset)
459 def _parse_meal_info(
460 self,
461 data: bytearray,
462 flags: GlucoseMeasurementContextFlags,
463 offset: int,
464 ) -> MealResult:
465 """Parse optional meal information field."""
466 meal: MealType | None = None
467 if GlucoseMeasurementContextFlags.MEAL_PRESENT in flags and len(data) >= offset + 1:
468 meal = MealType(data[offset])
469 offset += 1
470 return MealResult(meal=meal, offset=offset)
472 def _parse_tester_health_info(
473 self,
474 data: bytearray,
475 flags: GlucoseMeasurementContextFlags,
476 offset: int,
477 ) -> TesterHealthResult:
478 """Parse optional tester and health information field."""
479 tester: GlucoseTester | None = None
480 health: HealthType | None = None
481 if GlucoseMeasurementContextFlags.TESTER_HEALTH_PRESENT in flags and len(data) >= offset + 1:
482 tester_health = data[offset]
483 tester_raw = BitFieldUtils.extract_bit_field(
484 tester_health,
485 GlucoseMeasurementContextBits.TESTER_START_BIT,
486 GlucoseMeasurementContextBits.TESTER_BIT_WIDTH,
487 ) # Bits 4-7 (4 bits)
488 health_raw = BitFieldUtils.extract_bit_field(
489 tester_health,
490 GlucoseMeasurementContextBits.HEALTH_START_BIT,
491 GlucoseMeasurementContextBits.HEALTH_BIT_WIDTH,
492 ) # Bits 0-3 (4 bits)
493 tester = GlucoseTester(tester_raw)
494 health = HealthType(health_raw)
495 offset += 1
496 return TesterHealthResult(tester=tester, health=health, offset=offset)
498 def _parse_exercise_info(
499 self,
500 data: bytearray,
501 flags: GlucoseMeasurementContextFlags,
502 offset: int,
503 ) -> ExerciseResult:
504 """Parse optional exercise information field."""
505 exercise_duration_seconds: int | None = None
506 exercise_intensity_percent: int | None = None
507 if GlucoseMeasurementContextFlags.EXERCISE_PRESENT in flags and len(data) >= offset + 3:
508 exercise_duration_seconds = DataParser.parse_int16(data, offset, signed=False)
509 exercise_intensity_percent = int(data[offset + 2])
510 offset += 3
511 return ExerciseResult(
512 exercise_duration_seconds=exercise_duration_seconds,
513 exercise_intensity_percent=exercise_intensity_percent,
514 offset=offset,
515 )
517 def _parse_medication_info(
518 self,
519 data: bytearray,
520 flags: GlucoseMeasurementContextFlags,
521 offset: int,
522 ) -> MedicationResult:
523 """Parse optional medication information field."""
524 medication_id: MedicationType | None = None
525 medication_value: float | None = None
526 medication_unit: str | None = None
527 if GlucoseMeasurementContextFlags.MEDICATION_PRESENT in flags and len(data) >= offset + 3:
528 medication_id = MedicationType(data[offset])
529 medication_value = IEEE11073Parser.parse_sfloat(data, offset + 1)
530 medication_unit = "litres" if GlucoseMeasurementContextFlags.MEDICATION_UNITS in flags else "kg"
531 offset += 3
532 return MedicationResult(
533 medication_id=medication_id,
534 medication_value=medication_value,
535 medication_unit=medication_unit,
536 offset=offset,
537 )
539 def _parse_hba1c_info(
540 self,
541 data: bytearray,
542 flags: GlucoseMeasurementContextFlags,
543 offset: int,
544 ) -> float | None:
545 """Parse optional HbA1c information field.
547 Returns:
548 HbA1c percentage or None
550 """
551 hba1c_percent: float | None = None
552 if GlucoseMeasurementContextFlags.HBA1C_PRESENT in flags and len(data) >= offset + 2:
553 hba1c_percent = IEEE11073Parser.parse_sfloat(data, offset)
554 return hba1c_percent