Coverage for src / bluetooth_sig / gatt / characteristics / glucose_measurement.py: 80%
157 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 characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntEnum, IntFlag
7from typing import Any, ClassVar
9import msgspec
11from ..constants import SINT16_MAX, SINT16_MIN, UINT16_MAX
12from ..context import CharacteristicContext
13from .base import BaseCharacteristic
14from .glucose_feature import GlucoseFeatureCharacteristic, GlucoseFeatureData, GlucoseFeatures
15from .utils import BitFieldUtils, DataParser, IEEE11073Parser
18class GlucoseMeasurementBits:
19 """Glucose measurement bit field constants."""
21 # pylint: disable=missing-class-docstring,too-few-public-methods
23 # Type-Sample Location nibble packing per GSS YAML:
24 # Type = low nibble (bits 0-3), Sample Location = high nibble (bits 4-7)
25 GLUCOSE_TYPE_SAMPLE_MASK = 0x0F # 4-bit mask for type and sample location
26 GLUCOSE_TYPE_START_BIT = 0 # Glucose type in low 4 bits
27 GLUCOSE_TYPE_BIT_WIDTH = 4
28 GLUCOSE_SAMPLE_LOCATION_START_BIT = 4 # Sample location in high 4 bits
29 GLUCOSE_SAMPLE_LOCATION_BIT_WIDTH = 4
32class GlucoseType(IntEnum):
33 """Glucose sample type enumeration as per Bluetooth SIG specification."""
35 CAPILLARY_WHOLE_BLOOD = 1
36 CAPILLARY_PLASMA = 2
37 VENOUS_WHOLE_BLOOD = 3
38 VENOUS_PLASMA = 4
39 ARTERIAL_WHOLE_BLOOD = 5
40 ARTERIAL_PLASMA = 6
41 UNDETERMINED_WHOLE_BLOOD = 7
42 UNDETERMINED_PLASMA = 8
43 INTERSTITIAL_FLUID = 9
44 CONTROL_SOLUTION = 10
45 # Values 11-15 (0xB-0xF) are Reserved for Future Use
47 def __str__(self) -> str:
48 """Return human-readable glucose type name."""
49 names = {
50 self.CAPILLARY_WHOLE_BLOOD: "Capillary Whole blood",
51 self.CAPILLARY_PLASMA: "Capillary Plasma",
52 self.VENOUS_WHOLE_BLOOD: "Venous Whole blood",
53 self.VENOUS_PLASMA: "Venous Plasma",
54 self.ARTERIAL_WHOLE_BLOOD: "Arterial Whole blood",
55 self.ARTERIAL_PLASMA: "Arterial Plasma",
56 self.UNDETERMINED_WHOLE_BLOOD: "Undetermined Whole blood",
57 self.UNDETERMINED_PLASMA: "Undetermined Plasma",
58 self.INTERSTITIAL_FLUID: "Interstitial Fluid (ISF)",
59 self.CONTROL_SOLUTION: "Control Solution",
60 }
61 return names[self]
64class SampleLocation(IntEnum):
65 """Sample location enumeration as per Bluetooth SIG specification."""
67 # Value 0 is Reserved for Future Use
68 FINGER = 1
69 ALTERNATE_SITE_TEST = 2
70 EARLOBE = 3
71 CONTROL_SOLUTION = 4
72 # Values 5-14 (0x5-0xE) are Reserved for Future Use
73 NOT_AVAILABLE = 15
75 def __str__(self) -> str:
76 """Return human-readable sample location name."""
77 names = {
78 self.FINGER: "Finger",
79 self.ALTERNATE_SITE_TEST: "Alternate Site Test (AST)",
80 self.EARLOBE: "Earlobe",
81 self.CONTROL_SOLUTION: "Control solution",
82 self.NOT_AVAILABLE: "Sample Location value not available",
83 }
84 return names[self]
87class GlucoseMeasurementFlags(IntFlag):
88 """Glucose Measurement flags as per Bluetooth SIG GSS YAML.
90 Bit 0: Time Offset present
91 Bit 1: Glucose Concentration and Type-Sample Location present
92 Bit 2: Glucose Concentration units (0=mg/dL, 1=mmol/L)
93 Bit 3: Sensor Status Annunciation present
94 Bit 4: Context Information Follows
95 """
97 TIME_OFFSET_PRESENT = 0x01
98 CONCENTRATION_TYPE_SAMPLE_PRESENT = 0x02
99 GLUCOSE_CONCENTRATION_UNITS_MMOL_L = 0x04
100 SENSOR_STATUS_ANNUNCIATION_PRESENT = 0x08
101 CONTEXT_INFORMATION_FOLLOWS = 0x10
104class GlucoseMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
105 """Parsed glucose measurement data."""
107 sequence_number: int
108 base_time: datetime
109 flags: GlucoseMeasurementFlags
110 glucose_concentration: float | None = None
111 unit: str | None = None
112 time_offset_minutes: int | None = None
113 glucose_type: GlucoseType | None = None
114 sample_location: SampleLocation | None = None
115 sensor_status: int | None = None
117 min_length: int = 10 # flags(1) + seq(2) + base_time(7)
118 max_length: int = 17 # + time_offset(2) + concentration(2) + type_sample(1) + sensor_status(2)
120 def __post_init__(self) -> None:
121 """Validate glucose measurement data."""
122 if self.unit is not None and self.unit not in ("mg/dL", "mmol/L"):
123 raise ValueError(f"Glucose unit must be 'mg/dL' or 'mmol/L', got {self.unit}")
125 if self.glucose_concentration is not None and self.glucose_concentration < 0:
126 raise ValueError(f"Glucose concentration must be non-negative, got {self.glucose_concentration}")
128 @staticmethod
129 def is_reserved_range(value: int) -> bool:
130 """Check if glucose type or sample location is in reserved range."""
131 return value in {0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}
134class GlucoseMeasurementCharacteristic(BaseCharacteristic[GlucoseMeasurementData]):
135 """Glucose Measurement characteristic (0x2A18).
137 Used to transmit glucose concentration measurements with timestamps
138 and status. Core characteristic for glucose monitoring devices.
139 """
141 _manual_unit: str = "mg/dL or mmol/L" # Unit depends on flags
143 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [GlucoseFeatureCharacteristic]
145 min_length: int = 10 # flags(1) + seq(2) + base_time(7)
146 max_length: int = 17 # + time_offset(2) + concentration(2) + type_sample(1) + sensor_status(2)
147 allow_variable_length: bool = True # Variable optional fields
149 def _decode_value( # pylint: disable=too-many-locals # Glucose spec with many optional fields
150 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True
151 ) -> GlucoseMeasurementData:
152 """Parse glucose measurement data according to Bluetooth specification.
154 Format: Flags(1) + Sequence Number(2) + Base Time(7) + [Time Offset(2)] +
155 [Glucose Concentration(2) + Type-Sample Location(1)] + [Sensor Status(2)].
156 Concentration and Type-Sample Location are present together when bit 1 is set.
158 Args:
159 data: Raw bytearray from BLE characteristic.
160 ctx: Optional CharacteristicContext providing surrounding context (may be None).
161 validate: Whether to validate ranges (default True)
163 Returns:
164 GlucoseMeasurementData containing parsed glucose measurement data with metadata.
166 Raises:
167 ValueError: If data format is invalid.
169 """
170 flags = GlucoseMeasurementFlags(data[0])
171 offset = 1
173 # Parse sequence number (2 bytes)
174 sequence_number = DataParser.parse_int16(data, offset, signed=False)
175 offset += 2
177 # Parse base time (7 bytes) - IEEE-11073 timestamp
178 base_time = IEEE11073Parser.parse_timestamp(data, offset)
179 offset += 7
181 # Parse optional time offset (2 bytes) if present
182 time_offset_minutes = None
183 if GlucoseMeasurementFlags.TIME_OFFSET_PRESENT in flags and len(data) >= offset + 2:
184 time_offset_minutes = DataParser.parse_int16(data, offset, signed=True) # signed
185 offset += 2
187 # Parse glucose concentration + type-sample location (conditional on bit 1)
188 glucose_concentration: float | None = None
189 unit: str | None = None
190 glucose_type = None
191 sample_location = None
192 if GlucoseMeasurementFlags.CONCENTRATION_TYPE_SAMPLE_PRESENT in flags and len(data) >= offset + 3:
193 glucose_concentration = IEEE11073Parser.parse_sfloat(data, offset)
194 unit = "mmol/L" if GlucoseMeasurementFlags.GLUCOSE_CONCENTRATION_UNITS_MMOL_L in flags else "mg/dL"
195 offset += 2
197 type_sample = data[offset]
198 glucose_type_val = BitFieldUtils.extract_bit_field(
199 type_sample,
200 GlucoseMeasurementBits.GLUCOSE_TYPE_START_BIT,
201 GlucoseMeasurementBits.GLUCOSE_TYPE_BIT_WIDTH,
202 )
203 sample_location_val = BitFieldUtils.extract_bit_field(
204 type_sample,
205 GlucoseMeasurementBits.GLUCOSE_SAMPLE_LOCATION_START_BIT,
206 GlucoseMeasurementBits.GLUCOSE_SAMPLE_LOCATION_BIT_WIDTH,
207 )
209 glucose_type = GlucoseType(glucose_type_val)
210 sample_location = SampleLocation(sample_location_val)
211 offset += 1
213 # Parse optional sensor status annotation (2 bytes) if present
214 sensor_status = None
215 if GlucoseMeasurementFlags.SENSOR_STATUS_ANNUNCIATION_PRESENT in flags and len(data) >= offset + 2:
216 sensor_status = DataParser.parse_int16(data, offset, signed=False)
218 # Validate sensor status against Glucose Feature if available
219 if ctx is not None and sensor_status is not None:
220 feature_value = self.get_context_characteristic(ctx, GlucoseFeatureCharacteristic)
221 if feature_value is not None:
222 self._validate_sensor_status_against_feature(sensor_status, feature_value)
224 # Create result with all parsed values
225 return GlucoseMeasurementData(
226 sequence_number=sequence_number,
227 base_time=base_time,
228 flags=flags,
229 glucose_concentration=glucose_concentration,
230 unit=unit,
231 time_offset_minutes=time_offset_minutes,
232 glucose_type=glucose_type,
233 sample_location=sample_location,
234 sensor_status=sensor_status,
235 )
237 def _encode_value(self, data: GlucoseMeasurementData) -> bytearray: # pylint: disable=too-many-locals,too-many-branches # Complex medical data encoding
238 """Encode glucose measurement value back to bytes.
240 Args:
241 data: GlucoseMeasurementData containing glucose measurement data
243 Returns:
244 Encoded bytes representing the glucose measurement
246 """
247 # Build flags based on available data
248 flags = GlucoseMeasurementFlags(0)
249 if data.time_offset_minutes is not None:
250 flags |= GlucoseMeasurementFlags.TIME_OFFSET_PRESENT
251 if data.glucose_concentration is not None:
252 flags |= GlucoseMeasurementFlags.CONCENTRATION_TYPE_SAMPLE_PRESENT
253 if data.unit == "mmol/L":
254 flags |= GlucoseMeasurementFlags.GLUCOSE_CONCENTRATION_UNITS_MMOL_L
255 if data.sensor_status is not None:
256 flags |= GlucoseMeasurementFlags.SENSOR_STATUS_ANNUNCIATION_PRESENT
258 # Validate ranges
259 if not 0 <= data.sequence_number <= UINT16_MAX:
260 raise ValueError(f"Sequence number {data.sequence_number} exceeds uint16 range")
262 # Start with flags, sequence number, and base time
263 result = bytearray([int(flags)])
264 result.extend(DataParser.encode_int16(data.sequence_number, signed=False))
265 result.extend(IEEE11073Parser.encode_timestamp(data.base_time))
267 # Add optional time offset
268 if data.time_offset_minutes is not None:
269 if not SINT16_MIN <= data.time_offset_minutes <= SINT16_MAX:
270 raise ValueError(f"Time offset {data.time_offset_minutes} exceeds sint16 range")
271 result.extend(DataParser.encode_int16(data.time_offset_minutes, signed=True))
273 # Add glucose concentration + type-sample location (bit 1 controls both)
274 if data.glucose_concentration is not None:
275 result.extend(IEEE11073Parser.encode_sfloat(data.glucose_concentration))
276 glucose_type = data.glucose_type or 0
277 sample_location = data.sample_location or 0
278 type_sample = BitFieldUtils.merge_bit_fields(
279 (
280 glucose_type,
281 GlucoseMeasurementBits.GLUCOSE_TYPE_START_BIT,
282 GlucoseMeasurementBits.GLUCOSE_TYPE_BIT_WIDTH,
283 ),
284 (
285 sample_location,
286 GlucoseMeasurementBits.GLUCOSE_SAMPLE_LOCATION_START_BIT,
287 GlucoseMeasurementBits.GLUCOSE_SAMPLE_LOCATION_BIT_WIDTH,
288 ),
289 )
290 result.append(type_sample)
292 # Add optional sensor status
293 if data.sensor_status is not None:
294 if not 0 <= data.sensor_status <= UINT16_MAX:
295 raise ValueError(f"Sensor status {data.sensor_status} exceeds uint16 range")
296 result.extend(DataParser.encode_int16(data.sensor_status, signed=False))
298 return result
300 def _validate_sensor_status_against_feature(self, sensor_status: int, feature_data: GlucoseFeatureData) -> None:
301 """Validate sensor status bits against supported Glucose Features.
303 Args:
304 sensor_status: Raw sensor status bitmask from measurement
305 feature_data: GlucoseFeatureData from Glucose Feature characteristic
307 Raises:
308 ValueError: If reported sensor status bits are not supported by device features
310 """
311 # Sensor status bits correspond to Glucose Feature bits
312 # Check each status bit against corresponding feature support
313 if (sensor_status & GlucoseFeatures.LOW_BATTERY_DETECTION) and not feature_data.low_battery_detection:
314 raise ValueError("Low battery status reported but not supported by Glucose Feature")
315 if (
316 sensor_status & GlucoseFeatures.SENSOR_MALFUNCTION_DETECTION
317 ) and not feature_data.sensor_malfunction_detection:
318 raise ValueError("Sensor malfunction status reported but not supported by Glucose Feature")
319 if (sensor_status & GlucoseFeatures.SENSOR_SAMPLE_SIZE) and not feature_data.sensor_sample_size:
320 raise ValueError("Sensor sample size status reported but not supported by Glucose Feature")
321 if (
322 sensor_status & GlucoseFeatures.SENSOR_STRIP_INSERTION_ERROR
323 ) and not feature_data.sensor_strip_insertion_error:
324 raise ValueError("Sensor strip insertion error status reported but not supported by Glucose Feature")
325 if (sensor_status & GlucoseFeatures.SENSOR_STRIP_TYPE_ERROR) and not feature_data.sensor_strip_type_error:
326 raise ValueError("Sensor strip type error status reported but not supported by Glucose Feature")
327 if (sensor_status & GlucoseFeatures.SENSOR_RESULT_HIGH_LOW) and not feature_data.sensor_result_high_low:
328 raise ValueError("Sensor result high-low status reported but not supported by Glucose Feature")
329 if (
330 sensor_status & GlucoseFeatures.SENSOR_TEMPERATURE_HIGH_LOW
331 ) and not feature_data.sensor_temperature_high_low:
332 raise ValueError("Sensor temperature high-low status reported but not supported by Glucose Feature")
333 if (sensor_status & GlucoseFeatures.SENSOR_READ_INTERRUPT) and not feature_data.sensor_read_interrupt:
334 raise ValueError("Sensor read interrupt status reported but not supported by Glucose Feature")
335 if (sensor_status & GlucoseFeatures.GENERAL_DEVICE_FAULT) and not feature_data.general_device_fault:
336 raise ValueError("General device fault status reported but not supported by Glucose Feature")
337 if (sensor_status & GlucoseFeatures.TIME_FAULT) and not feature_data.time_fault:
338 raise ValueError("Time fault status reported but not supported by Glucose Feature")
339 if (sensor_status & GlucoseFeatures.MULTIPLE_BOND_SUPPORT) and not feature_data.multiple_bond_support:
340 raise ValueError("Multiple bond status reported but not supported by Glucose Feature")