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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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) 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
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
73 # Common constants
74 TIMESTAMP_LENGTH = 7
76 @staticmethod
77 def parse_sfloat(data: bytes | bytearray, offset: int = 0) -> float:
78 """Parse IEEE 11073 16-bit SFLOAT.
80 Args:
81 data: Raw bytes/bytearray
82 offset: Offset in the data
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")
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
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
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
111 return float(mantissa * (10.0**exponent))
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)
119 raw_value = int.from_bytes(data[offset : offset + 4], byteorder="little")
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)
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
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
143 return float(mantissa * (10**exponent))
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"))
155 # Find best exponent and mantissa representation
156 exponent = 0
157 mantissa = value
159 while abs(mantissa) >= IEEE11073Parser.SFLOAT_MANTISSA_MAX and exponent < IEEE11073Parser.SFLOAT_EXPONENT_MAX:
160 mantissa /= 10
161 exponent += 1
163 while abs(mantissa) < 1 and mantissa != 0 and exponent > IEEE11073Parser.SFLOAT_EXPONENT_MIN:
164 mantissa *= 10
165 exponent -= 1
167 mantissa_int = int(round(mantissa))
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
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"))
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"))
198 if value == 0.0:
199 return bytearray([0x00, 0x00, 0x00, 0x00])
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")
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))
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)
220 # Calculate the actual value this would represent
221 actual_value = mantissa_int * (10**exp)
222 error = abs(value - actual_value)
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
230 # If we found an exact representation, use it
231 if error == 0.0:
232 break
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 )
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
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"))
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)
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)
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 )
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 )