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

1"""Glucose Measurement characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from datetime import datetime 

6from enum import IntEnum, IntFlag 

7from typing import Any, ClassVar 

8 

9import msgspec 

10 

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 

16 

17 

18class GlucoseMeasurementBits: 

19 """Glucose measurement bit field constants.""" 

20 

21 # pylint: disable=missing-class-docstring,too-few-public-methods 

22 

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 

30 

31 

32class GlucoseType(IntEnum): 

33 """Glucose sample type enumeration as per Bluetooth SIG specification.""" 

34 

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 

46 

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] 

62 

63 

64class SampleLocation(IntEnum): 

65 """Sample location enumeration as per Bluetooth SIG specification.""" 

66 

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 

74 

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] 

85 

86 

87class GlucoseMeasurementFlags(IntFlag): 

88 """Glucose Measurement flags as per Bluetooth SIG GSS YAML. 

89 

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 """ 

96 

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 

102 

103 

104class GlucoseMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes 

105 """Parsed glucose measurement data.""" 

106 

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 

116 

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) 

119 

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}") 

124 

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}") 

127 

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} 

132 

133 

134class GlucoseMeasurementCharacteristic(BaseCharacteristic[GlucoseMeasurementData]): 

135 """Glucose Measurement characteristic (0x2A18). 

136 

137 Used to transmit glucose concentration measurements with timestamps 

138 and status. Core characteristic for glucose monitoring devices. 

139 """ 

140 

141 _manual_unit: str = "mg/dL or mmol/L" # Unit depends on flags 

142 

143 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [GlucoseFeatureCharacteristic] 

144 

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 

148 

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. 

153 

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. 

157 

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) 

162 

163 Returns: 

164 GlucoseMeasurementData containing parsed glucose measurement data with metadata. 

165 

166 Raises: 

167 ValueError: If data format is invalid. 

168 

169 """ 

170 flags = GlucoseMeasurementFlags(data[0]) 

171 offset = 1 

172 

173 # Parse sequence number (2 bytes) 

174 sequence_number = DataParser.parse_int16(data, offset, signed=False) 

175 offset += 2 

176 

177 # Parse base time (7 bytes) - IEEE-11073 timestamp 

178 base_time = IEEE11073Parser.parse_timestamp(data, offset) 

179 offset += 7 

180 

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 

186 

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 

196 

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 ) 

208 

209 glucose_type = GlucoseType(glucose_type_val) 

210 sample_location = SampleLocation(sample_location_val) 

211 offset += 1 

212 

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) 

217 

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) 

223 

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 ) 

236 

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. 

239 

240 Args: 

241 data: GlucoseMeasurementData containing glucose measurement data 

242 

243 Returns: 

244 Encoded bytes representing the glucose measurement 

245 

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 

257 

258 # Validate ranges 

259 if not 0 <= data.sequence_number <= UINT16_MAX: 

260 raise ValueError(f"Sequence number {data.sequence_number} exceeds uint16 range") 

261 

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)) 

266 

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)) 

272 

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) 

291 

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)) 

297 

298 return result 

299 

300 def _validate_sensor_status_against_feature(self, sensor_status: int, feature_data: GlucoseFeatureData) -> None: 

301 """Validate sensor status bits against supported Glucose Features. 

302 

303 Args: 

304 sensor_status: Raw sensor status bitmask from measurement 

305 feature_data: GlucoseFeatureData from Glucose Feature characteristic 

306 

307 Raises: 

308 ValueError: If reported sensor status bits are not supported by device features 

309 

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")