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

1"""Temperature Statistics characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5import math 

6 

7import msgspec 

8 

9from ..constants import SINT16_MAX, SINT16_MIN, TEMPERATURE_RESOLUTION, UINT8_MAX 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .utils import DataParser 

13 

14_TIME_EXP_BASE = 1.1 

15_TIME_EXP_OFFSET = 64 

16 

17 

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) 

23 

24 

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)) 

31 

32 

33class TemperatureStatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

34 """Data class for temperature statistics. 

35 

36 Four temperature values (0.01 C resolution) and a sensing duration 

37 encoded as Time Exponential 8 (seconds). 

38 """ 

39 

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) 

45 

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") 

60 

61 

62class TemperatureStatisticsCharacteristic( 

63 BaseCharacteristic[TemperatureStatisticsData], 

64): 

65 """Temperature Statistics characteristic (0x2B11). 

66 

67 org.bluetooth.characteristic.temperature_statistics 

68 

69 Statistics for Temperature measurements: average, standard deviation, 

70 minimum, maximum (all sint16, 0.01 C), and sensing duration 

71 (Time Exponential 8). 

72 """ 

73 

74 expected_length: int = 9 # 4 x sint16 + uint8 

75 min_length: int = 9 

76 

77 def _decode_value( 

78 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True 

79 ) -> TemperatureStatisticsData: 

80 """Parse temperature statistics. 

81 

82 Args: 

83 data: Raw bytes (9 bytes). 

84 ctx: Optional CharacteristicContext. 

85 validate: Whether to validate ranges (default True). 

86 

87 Returns: 

88 TemperatureStatisticsData. 

89 

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) 

96 

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 ) 

104 

105 def _encode_value(self, data: TemperatureStatisticsData) -> bytearray: 

106 """Encode temperature statistics. 

107 

108 Args: 

109 data: TemperatureStatisticsData instance. 

110 

111 Returns: 

112 Encoded bytes (9 bytes). 

113 

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) 

120 

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") 

131 

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