Coverage for src / bluetooth_sig / gatt / characteristics / utils / ieee11073_parser.py: 95%
169 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"""IEEE 11073 medical device format support utilities."""
3from __future__ import annotations
5import math
6import struct
7from datetime import MAXYEAR, datetime
9from ...exceptions import InsufficientDataError, ValueRangeError
10from .bit_field_utils import BitFieldUtils
13class IEEE11073Parser:
14 """Utility class for IEEE-11073 medical device format support."""
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
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
39 # IEEE-11073 FLOAT32 (32-bit) special values
40 # Per IEEE 11073-20601 and Bluetooth GSS, exponent=0 with reserved mantissas:
41 # - Mantissa 0x7FFFFE (+Inf), 0x7FFFFF (NaN), 0x800000 (NRes),
42 # 0x800001 (RFU), 0x800002 (-Inf)
43 FLOAT32_NAN = 0x007FFFFF # Exponent 0, mantissa 0x7FFFFF
44 FLOAT32_POSITIVE_INFINITY = 0x007FFFFE # Exponent 0, mantissa 0x7FFFFE
45 FLOAT32_NEGATIVE_INFINITY = 0x00800002 # Exponent 0, mantissa 0x800002
46 FLOAT32_NRES = 0x00800000 # Exponent 0, mantissa 0x800000
47 FLOAT32_RFU = 0x00800001 # Exponent 0, mantissa 0x800001 (Reserved)
48 FLOAT32_MANTISSA_MASK = 0x00FFFFFF
49 FLOAT32_MANTISSA_SIGN_BIT = 0x00800000
50 FLOAT32_MANTISSA_CONVERSION = 0x01000000
51 FLOAT32_EXPONENT_MASK = 0xFF
52 FLOAT32_EXPONENT_SIGN_BIT = 0x80
53 FLOAT32_EXPONENT_CONVERSION = 0x100
54 FLOAT32_MANTISSA_MAX = 8388608 # 2^23
55 FLOAT32_EXPONENT_MIN = -128
56 FLOAT32_EXPONENT_MAX = 127
57 FLOAT32_EXPONENT_BIAS = 128
58 # Bit field positions and widths (no magic numbers)
59 FLOAT32_MANTISSA_START_BIT = 0
60 FLOAT32_MANTISSA_BIT_WIDTH = 24
61 FLOAT32_EXPONENT_START_BIT = 24
62 FLOAT32_EXPONENT_BIT_WIDTH = 8
64 # IEEE-11073 timestamp validation constants
65 IEEE11073_MIN_YEAR = 1582 # Gregorian calendar adoption year (per Bluetooth SIG spec)
66 MONTH_MIN = 1
67 MONTH_MAX = 12
68 DAY_MIN = 1
69 DAY_MAX = 31 # Simplified validation - actual days depend on month/year
70 HOUR_MIN = 0
71 HOUR_MAX = 23 # 24-hour format
72 MINUTE_MIN = 0
73 MINUTE_MAX = 59
74 SECOND_MIN = 0
75 SECOND_MAX = 59
77 # Common constants
78 TIMESTAMP_LENGTH = 7
80 @staticmethod
81 def parse_sfloat(data: bytes | bytearray, offset: int = 0) -> float:
82 """Parse IEEE 11073 16-bit SFLOAT.
84 Args:
85 data: Raw bytes/bytearray
86 offset: Offset in the data
88 """
89 if len(data) < offset + 2:
90 raise InsufficientDataError("IEEE 11073 SFLOAT", data[offset:], 2)
91 raw_value = int.from_bytes(data[offset : offset + 2], byteorder="little")
93 # Handle special values
94 if raw_value == IEEE11073Parser.SFLOAT_NAN:
95 return float("nan") # NaN
96 if raw_value == IEEE11073Parser.SFLOAT_NRES:
97 return float("nan") # NRes (Not a valid result)
98 if raw_value == IEEE11073Parser.SFLOAT_POSITIVE_INFINITY:
99 return float("inf") # +INFINITY
100 if raw_value == IEEE11073Parser.SFLOAT_NEGATIVE_INFINITY:
101 return float("-inf") # -INFINITY
103 # Extract mantissa and exponent
104 mantissa = BitFieldUtils.extract_bits(raw_value, IEEE11073Parser.SFLOAT_MANTISSA_MASK)
105 if mantissa >= IEEE11073Parser.SFLOAT_MANTISSA_SIGN_BIT: # Negative mantissa
106 mantissa = mantissa - IEEE11073Parser.SFLOAT_MANTISSA_CONVERSION
108 exponent = BitFieldUtils.extract_bit_field_from_mask(
109 raw_value,
110 IEEE11073Parser.SFLOAT_EXPONENT_MASK,
111 IEEE11073Parser.SFLOAT_EXPONENT_START_BIT,
112 )
113 exponent = exponent - IEEE11073Parser.SFLOAT_EXPONENT_BIAS
115 return float(mantissa * (10.0**exponent))
117 @staticmethod
118 def parse_float32(data: bytes | bytearray, offset: int = 0) -> float:
119 """Parse IEEE 11073 32-bit FLOAT."""
120 if len(data) < offset + 4:
121 raise InsufficientDataError("IEEE 11073 FLOAT32", data[offset:], 4)
123 raw_value = int.from_bytes(data[offset : offset + 4], byteorder="little")
125 # Handle special values (similar to SFLOAT but 32-bit)
126 if raw_value == IEEE11073Parser.FLOAT32_NAN:
127 return float("nan")
128 if raw_value == IEEE11073Parser.FLOAT32_POSITIVE_INFINITY:
129 return float("inf")
130 if raw_value == IEEE11073Parser.FLOAT32_NEGATIVE_INFINITY:
131 return float("-inf")
132 if raw_value == IEEE11073Parser.FLOAT32_NRES:
133 return float("nan") # NRes (Not a valid result)
135 # Extract mantissa (24-bit) and exponent (8-bit)
136 mantissa = BitFieldUtils.extract_bits(raw_value, IEEE11073Parser.FLOAT32_MANTISSA_MASK)
137 if mantissa >= IEEE11073Parser.FLOAT32_MANTISSA_SIGN_BIT: # Negative mantissa
138 mantissa = mantissa - IEEE11073Parser.FLOAT32_MANTISSA_CONVERSION
140 exponent = BitFieldUtils.extract_bit_field_from_mask(
141 raw_value,
142 IEEE11073Parser.FLOAT32_EXPONENT_MASK,
143 IEEE11073Parser.FLOAT32_EXPONENT_START_BIT,
144 )
145 exponent = exponent - IEEE11073Parser.FLOAT32_EXPONENT_BIAS
147 return float(mantissa * (10**exponent))
149 @staticmethod
150 def encode_sfloat(value: float) -> bytearray:
151 """Encode float to IEEE 11073 16-bit SFLOAT."""
152 if math.isnan(value):
153 return bytearray(IEEE11073Parser.SFLOAT_NAN.to_bytes(2, byteorder="little"))
154 if math.isinf(value):
155 if value > 0:
156 return bytearray(IEEE11073Parser.SFLOAT_POSITIVE_INFINITY.to_bytes(2, byteorder="little"))
157 return bytearray(IEEE11073Parser.SFLOAT_NEGATIVE_INFINITY.to_bytes(2, byteorder="little"))
159 # Find best exponent and mantissa representation
160 exponent = 0
161 mantissa = value
163 while abs(mantissa) >= IEEE11073Parser.SFLOAT_MANTISSA_MAX and exponent < IEEE11073Parser.SFLOAT_EXPONENT_MAX:
164 mantissa /= 10
165 exponent += 1
167 while abs(mantissa) < 1 and mantissa != 0 and exponent > IEEE11073Parser.SFLOAT_EXPONENT_MIN:
168 mantissa *= 10
169 exponent -= 1
171 mantissa_int = int(round(mantissa))
173 # Pack into 16-bit value
174 exponent += IEEE11073Parser.SFLOAT_EXPONENT_BIAS # Add bias for storage
175 if mantissa_int < 0:
176 mantissa_int = mantissa_int + IEEE11073Parser.SFLOAT_MANTISSA_CONVERSION
178 raw_value = BitFieldUtils.merge_bit_fields(
179 (
180 mantissa_int,
181 IEEE11073Parser.SFLOAT_MANTISSA_START_BIT,
182 IEEE11073Parser.SFLOAT_MANTISSA_BIT_WIDTH,
183 ),
184 (
185 exponent,
186 IEEE11073Parser.SFLOAT_EXPONENT_START_BIT,
187 IEEE11073Parser.SFLOAT_EXPONENT_BIT_WIDTH,
188 ),
189 )
190 return bytearray(raw_value.to_bytes(2, byteorder="little"))
192 @staticmethod
193 def encode_float32(value: float) -> bytearray:
194 """Encode float to IEEE 11073 32-bit FLOAT."""
195 if math.isnan(value):
196 return bytearray(IEEE11073Parser.FLOAT32_NAN.to_bytes(4, byteorder="little"))
197 if math.isinf(value):
198 if value > 0:
199 return bytearray(IEEE11073Parser.FLOAT32_POSITIVE_INFINITY.to_bytes(4, byteorder="little"))
200 return bytearray(IEEE11073Parser.FLOAT32_NEGATIVE_INFINITY.to_bytes(4, byteorder="little"))
202 if value == 0.0:
203 return bytearray([0x00, 0x00, 0x00, 0x00])
205 # Find the best representation by trying different exponents
206 # IEEE-11073 32-bit FLOAT: 24-bit signed mantissa + 8-bit signed exponent
207 best_mantissa = 0
208 best_exponent = 0
209 best_error = float("inf")
211 # Try exponents from min to max (reasonable range for medical values)
212 for exp in range(
213 IEEE11073Parser.FLOAT32_EXPONENT_MIN,
214 IEEE11073Parser.FLOAT32_EXPONENT_MAX + 1,
215 ):
216 # Calculate what mantissa would be with this exponent
217 potential_mantissa = value * (10 ** (-exp))
219 # Check if mantissa fits in 24-bit signed range
220 if abs(potential_mantissa) < IEEE11073Parser.FLOAT32_MANTISSA_MAX:
221 # Round to integer
222 mantissa_int = round(potential_mantissa)
224 # Calculate the actual value this would represent
225 actual_value = mantissa_int * (10**exp)
226 error = abs(value - actual_value)
228 # Use this if it's better (less error) or if it's exact
229 if error < best_error:
230 best_error = error
231 best_mantissa = mantissa_int
232 best_exponent = exp
234 # If we found an exact representation, use it
235 if error == 0.0:
236 break
238 # Validate mantissa fits in 24-bit signed range
239 if abs(best_mantissa) >= IEEE11073Parser.FLOAT32_MANTISSA_MAX:
240 raise ValueRangeError(
241 "IEEE-11073 FLOAT32 mantissa",
242 best_mantissa,
243 -(IEEE11073Parser.FLOAT32_MANTISSA_MAX - 1),
244 IEEE11073Parser.FLOAT32_MANTISSA_MAX - 1,
245 )
247 # Pack into 32-bit value: mantissa (24-bit signed) + exponent (8-bit signed)
248 # Convert signed values to unsigned for bit packing
249 mantissa_unsigned = (
250 best_mantissa if best_mantissa >= 0 else best_mantissa + IEEE11073Parser.FLOAT32_MANTISSA_CONVERSION
251 )
252 exponent_unsigned = best_exponent + IEEE11073Parser.FLOAT32_EXPONENT_BIAS # Add bias for storage
254 raw_value = BitFieldUtils.merge_bit_fields(
255 (
256 mantissa_unsigned,
257 IEEE11073Parser.FLOAT32_MANTISSA_START_BIT,
258 IEEE11073Parser.FLOAT32_MANTISSA_BIT_WIDTH,
259 ),
260 (
261 exponent_unsigned,
262 IEEE11073Parser.FLOAT32_EXPONENT_START_BIT,
263 IEEE11073Parser.FLOAT32_EXPONENT_BIT_WIDTH,
264 ),
265 )
266 return bytearray(raw_value.to_bytes(4, byteorder="little"))
268 @staticmethod
269 def parse_timestamp(data: bytearray, offset: int) -> datetime:
270 """Parse IEEE-11073 timestamp format (7 bytes)."""
271 if len(data) < offset + IEEE11073Parser.TIMESTAMP_LENGTH:
272 raise InsufficientDataError("IEEE 11073 timestamp", data[offset:], IEEE11073Parser.TIMESTAMP_LENGTH)
274 timestamp_data = data[offset : offset + IEEE11073Parser.TIMESTAMP_LENGTH]
275 year, month, day, hours, minutes, seconds = struct.unpack("<HBBBBB", timestamp_data)
276 return datetime(year, month, day, hours, minutes, seconds)
278 @staticmethod
279 def encode_timestamp(timestamp: datetime) -> bytearray:
280 """Encode timestamp to IEEE-11073 7-byte format."""
281 # Validate ranges per IEEE-11073 specification
282 if not IEEE11073Parser.IEEE11073_MIN_YEAR <= timestamp.year <= MAXYEAR:
283 raise ValueRangeError("year", timestamp.year, IEEE11073Parser.IEEE11073_MIN_YEAR, MAXYEAR)
284 if not IEEE11073Parser.MONTH_MIN <= timestamp.month <= IEEE11073Parser.MONTH_MAX:
285 raise ValueRangeError(
286 "month",
287 timestamp.month,
288 IEEE11073Parser.MONTH_MIN,
289 IEEE11073Parser.MONTH_MAX,
290 )
291 if not IEEE11073Parser.DAY_MIN <= timestamp.day <= IEEE11073Parser.DAY_MAX:
292 raise ValueRangeError("day", timestamp.day, IEEE11073Parser.DAY_MIN, IEEE11073Parser.DAY_MAX)
293 if not IEEE11073Parser.HOUR_MIN <= timestamp.hour <= IEEE11073Parser.HOUR_MAX:
294 raise ValueRangeError(
295 "hour",
296 timestamp.hour,
297 IEEE11073Parser.HOUR_MIN,
298 IEEE11073Parser.HOUR_MAX,
299 )
300 if not IEEE11073Parser.MINUTE_MIN <= timestamp.minute <= IEEE11073Parser.MINUTE_MAX:
301 raise ValueRangeError(
302 "minute",
303 timestamp.minute,
304 IEEE11073Parser.MINUTE_MIN,
305 IEEE11073Parser.MINUTE_MAX,
306 )
307 if not IEEE11073Parser.SECOND_MIN <= timestamp.second <= IEEE11073Parser.SECOND_MAX:
308 raise ValueRangeError(
309 "second",
310 timestamp.second,
311 IEEE11073Parser.SECOND_MIN,
312 IEEE11073Parser.SECOND_MAX,
313 )
315 return bytearray(
316 struct.pack(
317 "<HBBBBB",
318 timestamp.year,
319 timestamp.month,
320 timestamp.day,
321 timestamp.hour,
322 timestamp.minute,
323 timestamp.second,
324 )
325 )