Coverage for src / bluetooth_sig / gatt / characteristics / temperature_statistics.py: 95%
60 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"""Temperature Statistics characteristic implementation."""
3from __future__ import annotations
5import math
7import msgspec
9from ..constants import SINT16_MAX, SINT16_MIN, TEMPERATURE_RESOLUTION, UINT8_MAX
10from ..context import CharacteristicContext
11from .base import BaseCharacteristic
12from .utils import DataParser
14_TIME_EXP_BASE = 1.1
15_TIME_EXP_OFFSET = 64
18def _decode_time_exponential(raw: int) -> float:
19 """Decode Time Exponential 8 raw value to seconds."""
20 if raw == 0:
21 return 0.0
22 return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET)
25def _encode_time_exponential(seconds: float) -> int:
26 """Encode seconds to Time Exponential 8 raw value."""
27 if seconds <= 0.0:
28 return 0
29 n = round(math.log(seconds) / math.log(_TIME_EXP_BASE) + _TIME_EXP_OFFSET)
30 return max(1, min(n, 0xFD))
33class TemperatureStatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
34 """Data class for temperature statistics.
36 Four temperature values (0.01 C resolution) and a sensing duration
37 encoded as Time Exponential 8 (seconds).
38 """
40 average: float # Average temperature in C
41 standard_deviation: float # Standard deviation in C
42 minimum: float # Minimum temperature in C
43 maximum: float # Maximum temperature in C
44 sensing_duration: float # Sensing duration in seconds (exponential encoding)
46 def __post_init__(self) -> None:
47 """Validate data fields."""
48 min_temp = SINT16_MIN * TEMPERATURE_RESOLUTION
49 max_temp = SINT16_MAX * TEMPERATURE_RESOLUTION
50 for name, val in [
51 ("average", self.average),
52 ("standard_deviation", self.standard_deviation),
53 ("minimum", self.minimum),
54 ("maximum", self.maximum),
55 ]:
56 if not min_temp <= val <= max_temp:
57 raise ValueError(f"{name} {val} C is outside valid range ({min_temp} to {max_temp})")
58 if self.sensing_duration < 0.0:
59 raise ValueError(f"Sensing duration {self.sensing_duration} s cannot be negative")
62class TemperatureStatisticsCharacteristic(
63 BaseCharacteristic[TemperatureStatisticsData],
64):
65 """Temperature Statistics characteristic (0x2B11).
67 org.bluetooth.characteristic.temperature_statistics
69 Statistics for Temperature measurements: average, standard deviation,
70 minimum, maximum (all sint16, 0.01 C), and sensing duration
71 (Time Exponential 8).
72 """
74 expected_length: int = 9 # 4 x sint16 + uint8
75 min_length: int = 9
77 def _decode_value(
78 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True
79 ) -> TemperatureStatisticsData:
80 """Parse temperature statistics.
82 Args:
83 data: Raw bytes (9 bytes).
84 ctx: Optional CharacteristicContext.
85 validate: Whether to validate ranges (default True).
87 Returns:
88 TemperatureStatisticsData.
90 """
91 avg_raw = DataParser.parse_int16(data, 0, signed=True)
92 std_raw = DataParser.parse_int16(data, 2, signed=True)
93 min_raw = DataParser.parse_int16(data, 4, signed=True)
94 max_raw = DataParser.parse_int16(data, 6, signed=True)
95 dur_raw = DataParser.parse_int8(data, 8, signed=False)
97 return TemperatureStatisticsData(
98 average=avg_raw * TEMPERATURE_RESOLUTION,
99 standard_deviation=std_raw * TEMPERATURE_RESOLUTION,
100 minimum=min_raw * TEMPERATURE_RESOLUTION,
101 maximum=max_raw * TEMPERATURE_RESOLUTION,
102 sensing_duration=_decode_time_exponential(dur_raw),
103 )
105 def _encode_value(self, data: TemperatureStatisticsData) -> bytearray:
106 """Encode temperature statistics.
108 Args:
109 data: TemperatureStatisticsData instance.
111 Returns:
112 Encoded bytes (9 bytes).
114 """
115 avg_raw = round(data.average / TEMPERATURE_RESOLUTION)
116 std_raw = round(data.standard_deviation / TEMPERATURE_RESOLUTION)
117 min_raw = round(data.minimum / TEMPERATURE_RESOLUTION)
118 max_raw = round(data.maximum / TEMPERATURE_RESOLUTION)
119 dur_raw = _encode_time_exponential(data.sensing_duration)
121 for name, value in [
122 ("average", avg_raw),
123 ("standard_deviation", std_raw),
124 ("minimum", min_raw),
125 ("maximum", max_raw),
126 ]:
127 if not SINT16_MIN <= value <= SINT16_MAX:
128 raise ValueError(f"{name} raw {value} exceeds sint16 range")
129 if not 0 <= dur_raw <= UINT8_MAX:
130 raise ValueError(f"Duration raw {dur_raw} exceeds uint8 range")
132 result = bytearray()
133 result.extend(DataParser.encode_int16(avg_raw, signed=True))
134 result.extend(DataParser.encode_int16(std_raw, signed=True))
135 result.extend(DataParser.encode_int16(min_raw, signed=True))
136 result.extend(DataParser.encode_int16(max_raw, signed=True))
137 result.extend(DataParser.encode_int8(dur_raw, signed=False))
138 return result