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