Coverage for src / bluetooth_sig / gatt / characteristics / temperature_8_statistics.py: 95%

61 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

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

2 

3from __future__ import annotations 

4 

5import math 

6 

7import msgspec 

8 

9from ..constants import SINT8_MAX, SINT8_MIN, UINT8_MAX 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .utils import DataParser 

13 

14_TEMPERATURE_8_RESOLUTION = 0.5 # Temperature 8: M=1, d=0, b=-1 -> 0.5 C 

15_TIME_EXP_BASE = 1.1 

16_TIME_EXP_OFFSET = 64 

17 

18 

19def _decode_time_exponential(raw: int) -> float: 

20 """Decode Time Exponential 8 raw value to seconds.""" 

21 if raw == 0: 

22 return 0.0 

23 return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET) 

24 

25 

26def _encode_time_exponential(seconds: float) -> int: 

27 """Encode seconds to Time Exponential 8 raw value.""" 

28 if seconds <= 0.0: 

29 return 0 

30 n = round(math.log(seconds) / math.log(_TIME_EXP_BASE) + _TIME_EXP_OFFSET) 

31 return max(1, min(n, 0xFD)) 

32 

33 

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

35 """Data class for temperature 8 statistics. 

36 

37 Four temperature values (0.5 C resolution) and a sensing duration 

38 encoded as Time Exponential 8 (seconds). 

39 """ 

40 

41 average: float # Average temperature in C 

42 standard_deviation: float # Standard deviation in C 

43 minimum: float # Minimum temperature in C 

44 maximum: float # Maximum temperature in C 

45 sensing_duration: float # Sensing duration in seconds (exponential encoding) 

46 

47 def __post_init__(self) -> None: 

48 """Validate data fields.""" 

49 min_temp = SINT8_MIN * _TEMPERATURE_8_RESOLUTION 

50 max_temp = SINT8_MAX * _TEMPERATURE_8_RESOLUTION 

51 for name, val in [ 

52 ("average", self.average), 

53 ("standard_deviation", self.standard_deviation), 

54 ("minimum", self.minimum), 

55 ("maximum", self.maximum), 

56 ]: 

57 if not min_temp <= val <= max_temp: 

58 raise ValueError(f"{name} {val} C is outside valid range ({min_temp} to {max_temp})") 

59 if self.sensing_duration < 0.0: 

60 raise ValueError(f"Sensing duration {self.sensing_duration} s cannot be negative") 

61 

62 

63class Temperature8StatisticsCharacteristic( 

64 BaseCharacteristic[Temperature8StatisticsData], 

65): 

66 """Temperature 8 Statistics characteristic (0x2B0F). 

67 

68 org.bluetooth.characteristic.temperature_8_statistics 

69 

70 Statistics for Temperature 8 measurements: average, standard deviation, 

71 minimum, maximum (all sint8, 0.5 C), and sensing duration 

72 (Time Exponential 8). 

73 """ 

74 

75 expected_length: int = 5 # 4 x sint8 + uint8 

76 min_length: int = 5 

77 

78 def _decode_value( 

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

80 ) -> Temperature8StatisticsData: 

81 """Parse temperature 8 statistics. 

82 

83 Args: 

84 data: Raw bytes (5 bytes). 

85 ctx: Optional CharacteristicContext. 

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

87 

88 Returns: 

89 Temperature8StatisticsData. 

90 

91 """ 

92 avg_raw = DataParser.parse_int8(data, 0, signed=True) 

93 std_raw = DataParser.parse_int8(data, 1, signed=True) 

94 min_raw = DataParser.parse_int8(data, 2, signed=True) 

95 max_raw = DataParser.parse_int8(data, 3, signed=True) 

96 dur_raw = DataParser.parse_int8(data, 4, signed=False) 

97 

98 return Temperature8StatisticsData( 

99 average=avg_raw * _TEMPERATURE_8_RESOLUTION, 

100 standard_deviation=std_raw * _TEMPERATURE_8_RESOLUTION, 

101 minimum=min_raw * _TEMPERATURE_8_RESOLUTION, 

102 maximum=max_raw * _TEMPERATURE_8_RESOLUTION, 

103 sensing_duration=_decode_time_exponential(dur_raw), 

104 ) 

105 

106 def _encode_value(self, data: Temperature8StatisticsData) -> bytearray: 

107 """Encode temperature 8 statistics. 

108 

109 Args: 

110 data: Temperature8StatisticsData instance. 

111 

112 Returns: 

113 Encoded bytes (5 bytes). 

114 

115 """ 

116 avg_raw = round(data.average / _TEMPERATURE_8_RESOLUTION) 

117 std_raw = round(data.standard_deviation / _TEMPERATURE_8_RESOLUTION) 

118 min_raw = round(data.minimum / _TEMPERATURE_8_RESOLUTION) 

119 max_raw = round(data.maximum / _TEMPERATURE_8_RESOLUTION) 

120 dur_raw = _encode_time_exponential(data.sensing_duration) 

121 

122 for name, value in [ 

123 ("average", avg_raw), 

124 ("standard_deviation", std_raw), 

125 ("minimum", min_raw), 

126 ("maximum", max_raw), 

127 ]: 

128 if not SINT8_MIN <= value <= SINT8_MAX: 

129 raise ValueError(f"{name} raw {value} exceeds sint8 range") 

130 if not 0 <= dur_raw <= UINT8_MAX: 

131 raise ValueError(f"Duration raw {dur_raw} exceeds uint8 range") 

132 

133 result = bytearray() 

134 result.extend(DataParser.encode_int8(avg_raw, signed=True)) 

135 result.extend(DataParser.encode_int8(std_raw, signed=True)) 

136 result.extend(DataParser.encode_int8(min_raw, signed=True)) 

137 result.extend(DataParser.encode_int8(max_raw, signed=True)) 

138 result.extend(DataParser.encode_int8(dur_raw, signed=False)) 

139 return result