Coverage for src/bluetooth_sig/gatt/characteristics/utils/ieee11073_parser.py: 96%

168 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""IEEE 11073 medical device format support utilities.""" 

2 

3from __future__ import annotations 

4 

5import math 

6import struct 

7from datetime import MAXYEAR, datetime 

8 

9from ...exceptions import InsufficientDataError, ValueRangeError 

10from .bit_field_utils import BitFieldUtils 

11 

12 

13class IEEE11073Parser: 

14 """Utility class for IEEE-11073 medical device format support.""" 

15 

16 # IEEE-11073 SFLOAT (16-bit) constants 

17 SFLOAT_MANTISSA_MASK = 0x0FFF 

18 SFLOAT_MANTISSA_SIGN_BIT = 0x0800 

19 SFLOAT_MANTISSA_CONVERSION = 0x1000 

20 SFLOAT_EXPONENT_MASK = 0x0F 

21 SFLOAT_EXPONENT_SIGN_BIT = 0x08 

22 SFLOAT_EXPONENT_CONVERSION = 0x10 

23 SFLOAT_MANTISSA_MAX = 2048 

24 SFLOAT_EXPONENT_MIN = -8 

25 SFLOAT_EXPONENT_MAX = 7 

26 SFLOAT_EXPONENT_BIAS = 8 

27 # Bit field positions and widths (no magic numbers) 

28 SFLOAT_MANTISSA_START_BIT = 0 

29 SFLOAT_MANTISSA_BIT_WIDTH = 12 

30 SFLOAT_EXPONENT_START_BIT = 12 

31 SFLOAT_EXPONENT_BIT_WIDTH = 4 

32 

33 # IEEE-11073 SFLOAT (16-bit) special values 

34 SFLOAT_NAN = 0x07FF 

35 SFLOAT_NRES = 0x0800 # NRes (Not a valid result) - treated as NaN 

36 SFLOAT_POSITIVE_INFINITY = 0x07FE 

37 SFLOAT_NEGATIVE_INFINITY = 0x0802 

38 

39 # IEEE-11073 FLOAT32 (32-bit) constants 

40 FLOAT32_NAN = 0x007FFFFF 

41 FLOAT32_POSITIVE_INFINITY = 0x00800000 

42 FLOAT32_NEGATIVE_INFINITY = 0x00800001 

43 FLOAT32_NRES = 0x00800002 

44 FLOAT32_MANTISSA_MASK = 0x00FFFFFF 

45 FLOAT32_MANTISSA_SIGN_BIT = 0x00800000 

46 FLOAT32_MANTISSA_CONVERSION = 0x01000000 

47 FLOAT32_EXPONENT_MASK = 0xFF 

48 FLOAT32_EXPONENT_SIGN_BIT = 0x80 

49 FLOAT32_EXPONENT_CONVERSION = 0x100 

50 FLOAT32_MANTISSA_MAX = 8388608 # 2^23 

51 FLOAT32_EXPONENT_MIN = -128 

52 FLOAT32_EXPONENT_MAX = 127 

53 FLOAT32_EXPONENT_BIAS = 128 

54 # Bit field positions and widths (no magic numbers) 

55 FLOAT32_MANTISSA_START_BIT = 0 

56 FLOAT32_MANTISSA_BIT_WIDTH = 24 

57 FLOAT32_EXPONENT_START_BIT = 24 

58 FLOAT32_EXPONENT_BIT_WIDTH = 8 

59 

60 # IEEE-11073 timestamp validation constants 

61 IEEE11073_MIN_YEAR = 1582 # Gregorian calendar adoption year (per Bluetooth SIG spec) 

62 MONTH_MIN = 1 

63 MONTH_MAX = 12 

64 DAY_MIN = 1 

65 DAY_MAX = 31 # Simplified validation - actual days depend on month/year 

66 HOUR_MIN = 0 

67 HOUR_MAX = 23 # 24-hour format 

68 MINUTE_MIN = 0 

69 MINUTE_MAX = 59 

70 SECOND_MIN = 0 

71 SECOND_MAX = 59 

72 

73 # Common constants 

74 TIMESTAMP_LENGTH = 7 

75 

76 @staticmethod 

77 def parse_sfloat(data: bytes | bytearray, offset: int = 0) -> float: 

78 """Parse IEEE 11073 16-bit SFLOAT. 

79 

80 Args: 

81 data: Raw bytes/bytearray 

82 offset: Offset in the data 

83 

84 """ 

85 if len(data) < offset + 2: 

86 raise InsufficientDataError("IEEE 11073 SFLOAT", data[offset:], 2) 

87 raw_value = int.from_bytes(data[offset : offset + 2], byteorder="little") 

88 

89 # Handle special values 

90 if raw_value == IEEE11073Parser.SFLOAT_NAN: 

91 return float("nan") # NaN 

92 if raw_value == IEEE11073Parser.SFLOAT_NRES: 

93 return float("nan") # NRes (Not a valid result) 

94 if raw_value == IEEE11073Parser.SFLOAT_POSITIVE_INFINITY: 

95 return float("inf") # +INFINITY 

96 if raw_value == IEEE11073Parser.SFLOAT_NEGATIVE_INFINITY: 

97 return float("-inf") # -INFINITY 

98 

99 # Extract mantissa and exponent 

100 mantissa = BitFieldUtils.extract_bits(raw_value, IEEE11073Parser.SFLOAT_MANTISSA_MASK) 

101 if mantissa >= IEEE11073Parser.SFLOAT_MANTISSA_SIGN_BIT: # Negative mantissa 

102 mantissa = mantissa - IEEE11073Parser.SFLOAT_MANTISSA_CONVERSION 

103 

104 exponent = BitFieldUtils.extract_bit_field_from_mask( 

105 raw_value, 

106 IEEE11073Parser.SFLOAT_EXPONENT_MASK, 

107 IEEE11073Parser.SFLOAT_EXPONENT_START_BIT, 

108 ) 

109 exponent = exponent - IEEE11073Parser.SFLOAT_EXPONENT_BIAS 

110 

111 return float(mantissa * (10.0**exponent)) 

112 

113 @staticmethod 

114 def parse_float32(data: bytes | bytearray, offset: int = 0) -> float: 

115 """Parse IEEE 11073 32-bit FLOAT.""" 

116 if len(data) < offset + 4: 

117 raise InsufficientDataError("IEEE 11073 FLOAT32", data[offset:], 4) 

118 

119 raw_value = int.from_bytes(data[offset : offset + 4], byteorder="little") 

120 

121 # Handle special values (similar to SFLOAT but 32-bit) 

122 if raw_value == IEEE11073Parser.FLOAT32_NAN: 

123 return float("nan") 

124 if raw_value == IEEE11073Parser.FLOAT32_POSITIVE_INFINITY: 

125 return float("inf") 

126 if raw_value == IEEE11073Parser.FLOAT32_NEGATIVE_INFINITY: 

127 return float("-inf") 

128 if raw_value == IEEE11073Parser.FLOAT32_NRES: 

129 return float("nan") # NRes (Not a valid result) 

130 

131 # Extract mantissa (24-bit) and exponent (8-bit) 

132 mantissa = BitFieldUtils.extract_bits(raw_value, IEEE11073Parser.FLOAT32_MANTISSA_MASK) 

133 if mantissa >= IEEE11073Parser.FLOAT32_MANTISSA_SIGN_BIT: # Negative mantissa 

134 mantissa = mantissa - IEEE11073Parser.FLOAT32_MANTISSA_CONVERSION 

135 

136 exponent = BitFieldUtils.extract_bit_field_from_mask( 

137 raw_value, 

138 IEEE11073Parser.FLOAT32_EXPONENT_MASK, 

139 IEEE11073Parser.FLOAT32_EXPONENT_START_BIT, 

140 ) 

141 exponent = exponent - IEEE11073Parser.FLOAT32_EXPONENT_BIAS 

142 

143 return float(mantissa * (10**exponent)) 

144 

145 @staticmethod 

146 def encode_sfloat(value: float) -> bytearray: 

147 """Encode float to IEEE 11073 16-bit SFLOAT.""" 

148 if math.isnan(value): 

149 return bytearray(IEEE11073Parser.SFLOAT_NAN.to_bytes(2, byteorder="little")) 

150 if math.isinf(value): 

151 if value > 0: 

152 return bytearray(IEEE11073Parser.SFLOAT_POSITIVE_INFINITY.to_bytes(2, byteorder="little")) 

153 return bytearray(IEEE11073Parser.SFLOAT_NEGATIVE_INFINITY.to_bytes(2, byteorder="little")) 

154 

155 # Find best exponent and mantissa representation 

156 exponent = 0 

157 mantissa = value 

158 

159 while abs(mantissa) >= IEEE11073Parser.SFLOAT_MANTISSA_MAX and exponent < IEEE11073Parser.SFLOAT_EXPONENT_MAX: 

160 mantissa /= 10 

161 exponent += 1 

162 

163 while abs(mantissa) < 1 and mantissa != 0 and exponent > IEEE11073Parser.SFLOAT_EXPONENT_MIN: 

164 mantissa *= 10 

165 exponent -= 1 

166 

167 mantissa_int = int(round(mantissa)) 

168 

169 # Pack into 16-bit value 

170 exponent += IEEE11073Parser.SFLOAT_EXPONENT_BIAS # Add bias for storage 

171 if mantissa_int < 0: 

172 mantissa_int = mantissa_int + IEEE11073Parser.SFLOAT_MANTISSA_CONVERSION 

173 

174 raw_value = BitFieldUtils.merge_bit_fields( 

175 ( 

176 mantissa_int, 

177 IEEE11073Parser.SFLOAT_MANTISSA_START_BIT, 

178 IEEE11073Parser.SFLOAT_MANTISSA_BIT_WIDTH, 

179 ), 

180 ( 

181 exponent, 

182 IEEE11073Parser.SFLOAT_EXPONENT_START_BIT, 

183 IEEE11073Parser.SFLOAT_EXPONENT_BIT_WIDTH, 

184 ), 

185 ) 

186 return bytearray(raw_value.to_bytes(2, byteorder="little")) 

187 

188 @staticmethod 

189 def encode_float32(value: float) -> bytearray: 

190 """Encode float to IEEE 11073 32-bit FLOAT.""" 

191 if math.isnan(value): 

192 return bytearray(IEEE11073Parser.FLOAT32_NAN.to_bytes(4, byteorder="little")) 

193 if math.isinf(value): 

194 if value > 0: 

195 return bytearray(IEEE11073Parser.FLOAT32_POSITIVE_INFINITY.to_bytes(4, byteorder="little")) 

196 return bytearray(IEEE11073Parser.FLOAT32_NEGATIVE_INFINITY.to_bytes(4, byteorder="little")) 

197 

198 if value == 0.0: 

199 return bytearray([0x00, 0x00, 0x00, 0x00]) 

200 

201 # Find the best representation by trying different exponents 

202 # IEEE-11073 32-bit FLOAT: 24-bit signed mantissa + 8-bit signed exponent 

203 best_mantissa = 0 

204 best_exponent = 0 

205 best_error = float("inf") 

206 

207 # Try exponents from min to max (reasonable range for medical values) 

208 for exp in range( 

209 IEEE11073Parser.FLOAT32_EXPONENT_MIN, 

210 IEEE11073Parser.FLOAT32_EXPONENT_MAX + 1, 

211 ): 

212 # Calculate what mantissa would be with this exponent 

213 potential_mantissa = value * (10 ** (-exp)) 

214 

215 # Check if mantissa fits in 24-bit signed range 

216 if abs(potential_mantissa) < IEEE11073Parser.FLOAT32_MANTISSA_MAX: 

217 # Round to integer 

218 mantissa_int = round(potential_mantissa) 

219 

220 # Calculate the actual value this would represent 

221 actual_value = mantissa_int * (10**exp) 

222 error = abs(value - actual_value) 

223 

224 # Use this if it's better (less error) or if it's exact 

225 if error < best_error: 

226 best_error = error 

227 best_mantissa = mantissa_int 

228 best_exponent = exp 

229 

230 # If we found an exact representation, use it 

231 if error == 0.0: 

232 break 

233 

234 # Validate mantissa fits in 24-bit signed range 

235 if abs(best_mantissa) >= IEEE11073Parser.FLOAT32_MANTISSA_MAX: 

236 raise ValueRangeError( 

237 "IEEE-11073 FLOAT32 mantissa", 

238 best_mantissa, 

239 -(IEEE11073Parser.FLOAT32_MANTISSA_MAX - 1), 

240 IEEE11073Parser.FLOAT32_MANTISSA_MAX - 1, 

241 ) 

242 

243 # Pack into 32-bit value: mantissa (24-bit signed) + exponent (8-bit signed) 

244 # Convert signed values to unsigned for bit packing 

245 mantissa_unsigned = ( 

246 best_mantissa if best_mantissa >= 0 else best_mantissa + IEEE11073Parser.FLOAT32_MANTISSA_CONVERSION 

247 ) 

248 exponent_unsigned = best_exponent + IEEE11073Parser.FLOAT32_EXPONENT_BIAS # Add bias for storage 

249 

250 raw_value = BitFieldUtils.merge_bit_fields( 

251 ( 

252 mantissa_unsigned, 

253 IEEE11073Parser.FLOAT32_MANTISSA_START_BIT, 

254 IEEE11073Parser.FLOAT32_MANTISSA_BIT_WIDTH, 

255 ), 

256 ( 

257 exponent_unsigned, 

258 IEEE11073Parser.FLOAT32_EXPONENT_START_BIT, 

259 IEEE11073Parser.FLOAT32_EXPONENT_BIT_WIDTH, 

260 ), 

261 ) 

262 return bytearray(raw_value.to_bytes(4, byteorder="little")) 

263 

264 @staticmethod 

265 def parse_timestamp(data: bytearray, offset: int) -> datetime: 

266 """Parse IEEE-11073 timestamp format (7 bytes).""" 

267 if len(data) < offset + IEEE11073Parser.TIMESTAMP_LENGTH: 

268 raise InsufficientDataError("IEEE 11073 timestamp", data[offset:], IEEE11073Parser.TIMESTAMP_LENGTH) 

269 

270 timestamp_data = data[offset : offset + IEEE11073Parser.TIMESTAMP_LENGTH] 

271 year, month, day, hours, minutes, seconds = struct.unpack("<HBBBBB", timestamp_data) 

272 return datetime(year, month, day, hours, minutes, seconds) 

273 

274 @staticmethod 

275 def encode_timestamp(timestamp: datetime) -> bytearray: 

276 """Encode timestamp to IEEE-11073 7-byte format.""" 

277 # Validate ranges per IEEE-11073 specification 

278 if not IEEE11073Parser.IEEE11073_MIN_YEAR <= timestamp.year <= MAXYEAR: 

279 raise ValueRangeError("year", timestamp.year, IEEE11073Parser.IEEE11073_MIN_YEAR, MAXYEAR) 

280 if not IEEE11073Parser.MONTH_MIN <= timestamp.month <= IEEE11073Parser.MONTH_MAX: 

281 raise ValueRangeError( 

282 "month", 

283 timestamp.month, 

284 IEEE11073Parser.MONTH_MIN, 

285 IEEE11073Parser.MONTH_MAX, 

286 ) 

287 if not IEEE11073Parser.DAY_MIN <= timestamp.day <= IEEE11073Parser.DAY_MAX: 

288 raise ValueRangeError("day", timestamp.day, IEEE11073Parser.DAY_MIN, IEEE11073Parser.DAY_MAX) 

289 if not IEEE11073Parser.HOUR_MIN <= timestamp.hour <= IEEE11073Parser.HOUR_MAX: 

290 raise ValueRangeError( 

291 "hour", 

292 timestamp.hour, 

293 IEEE11073Parser.HOUR_MIN, 

294 IEEE11073Parser.HOUR_MAX, 

295 ) 

296 if not IEEE11073Parser.MINUTE_MIN <= timestamp.minute <= IEEE11073Parser.MINUTE_MAX: 

297 raise ValueRangeError( 

298 "minute", 

299 timestamp.minute, 

300 IEEE11073Parser.MINUTE_MIN, 

301 IEEE11073Parser.MINUTE_MAX, 

302 ) 

303 if not IEEE11073Parser.SECOND_MIN <= timestamp.second <= IEEE11073Parser.SECOND_MAX: 

304 raise ValueRangeError( 

305 "second", 

306 timestamp.second, 

307 IEEE11073Parser.SECOND_MIN, 

308 IEEE11073Parser.SECOND_MAX, 

309 ) 

310 

311 return bytearray( 

312 struct.pack( 

313 "<HBBBBB", 

314 timestamp.year, 

315 timestamp.month, 

316 timestamp.day, 

317 timestamp.hour, 

318 timestamp.minute, 

319 timestamp.second, 

320 ) 

321 )