Coverage for src / bluetooth_sig / gatt / characteristics / voltage_statistics.py: 91%

64 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

1"""Voltage Statistics characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5import math 

6 

7import msgspec 

8 

9from ..constants import UINT8_MAX, UINT16_MAX 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .utils import DataParser 

13 

14_VOLTAGE_RESOLUTION = 1 / 64.0 # 1/64 V per raw unit 

15_MAX_VOLTAGE = UINT16_MAX * _VOLTAGE_RESOLUTION # ~1023.98 V 

16_TIME_EXP_BASE = 1.1 

17_TIME_EXP_OFFSET = 64 

18 

19 

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

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

22 if raw == 0: 

23 return 0.0 

24 return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET) 

25 

26 

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

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

29 if seconds <= 0.0: 

30 return 0 

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

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

33 

34 

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

36 """Data class for voltage statistics. 

37 

38 Five fields per spec: Average (uint16, 1/64 V), Standard Deviation 

39 (uint16, 1/64 V), Minimum (uint16, 1/64 V), Maximum (uint16, 1/64 V), 

40 and Sensing Duration (uint8, Time Exponential 8). 

41 """ 

42 

43 average: float # Average voltage in Volts 

44 standard_deviation: float # Standard deviation in Volts 

45 minimum: float # Minimum voltage in Volts 

46 maximum: float # Maximum voltage in Volts 

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

48 

49 def __post_init__(self) -> None: 

50 """Validate voltage statistics data.""" 

51 if self.minimum > self.maximum: 

52 raise ValueError(f"Minimum voltage {self.minimum} V cannot be greater than maximum {self.maximum} V") 

53 

54 for name, voltage in [ 

55 ("average", self.average), 

56 ("standard_deviation", self.standard_deviation), 

57 ("minimum", self.minimum), 

58 ("maximum", self.maximum), 

59 ]: 

60 if not 0.0 <= voltage <= _MAX_VOLTAGE: 

61 raise ValueError( 

62 f"{name.capitalize()} voltage {voltage} V is outside valid range (0.0 to {_MAX_VOLTAGE:.2f} V)" 

63 ) 

64 if self.sensing_duration < 0.0: 

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

66 

67 

68class VoltageStatisticsCharacteristic(BaseCharacteristic[VoltageStatisticsData]): 

69 """Voltage Statistics characteristic (0x2B1A). 

70 

71 org.bluetooth.characteristic.voltage_statistics 

72 

73 Statistics for Voltage measurements: average, standard deviation, 

74 minimum, maximum (all uint16, 1/64 V), and sensing duration 

75 (Time Exponential 8). 

76 """ 

77 

78 expected_length: int = 9 # 4x uint16 + uint8 

79 min_length: int = 9 

80 

81 def _decode_value( 

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

83 ) -> VoltageStatisticsData: 

84 """Parse voltage statistics data (4x uint16 + uint8).""" 

85 avg_raw = DataParser.parse_int16(data, 0, signed=False) 

86 std_raw = DataParser.parse_int16(data, 2, signed=False) 

87 min_raw = DataParser.parse_int16(data, 4, signed=False) 

88 max_raw = DataParser.parse_int16(data, 6, signed=False) 

89 dur_raw = DataParser.parse_int8(data, 8, signed=False) 

90 

91 return VoltageStatisticsData( 

92 average=avg_raw / 64.0, 

93 standard_deviation=std_raw / 64.0, 

94 minimum=min_raw / 64.0, 

95 maximum=max_raw / 64.0, 

96 sensing_duration=_decode_time_exponential(dur_raw), 

97 ) 

98 

99 def _encode_value(self, data: VoltageStatisticsData) -> bytearray: 

100 """Encode voltage statistics value back to bytes.""" 

101 if not isinstance(data, VoltageStatisticsData): 

102 raise TypeError(f"Voltage statistics data must be a VoltageStatisticsData, got {type(data).__name__}") 

103 

104 avg_raw = round(data.average * 64) 

105 std_raw = round(data.standard_deviation * 64) 

106 min_raw = round(data.minimum * 64) 

107 max_raw = round(data.maximum * 64) 

108 dur_raw = _encode_time_exponential(data.sensing_duration) 

109 

110 # pylint: disable=duplicate-code 

111 for name, value in [ 

112 ("average", avg_raw), 

113 ("standard_deviation", std_raw), 

114 ("minimum", min_raw), 

115 ("maximum", max_raw), 

116 ]: 

117 if not 0 <= value <= UINT16_MAX: 

118 raise ValueError(f"Voltage {name} value {value} exceeds uint16 range") 

119 if not 0 <= dur_raw <= UINT8_MAX: 

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

121 

122 result = bytearray() 

123 result.extend(DataParser.encode_int16(avg_raw, signed=False)) 

124 result.extend(DataParser.encode_int16(std_raw, signed=False)) 

125 result.extend(DataParser.encode_int16(min_raw, signed=False)) 

126 result.extend(DataParser.encode_int16(max_raw, signed=False)) 

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

128 

129 return result