Coverage for src / bluetooth_sig / gatt / characteristics / event_statistics.py: 93%

55 statements  

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

1"""Event Statistics characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5import math 

6 

7import msgspec 

8 

9from ..constants import UINT16_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 EventStatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

34 """Data class for event statistics. 

35 

36 Event count (uint16), average event duration (uint16, seconds), 

37 time elapsed since last event (Time Exponential 8, seconds), 

38 and sensing duration (Time Exponential 8, seconds). 

39 """ 

40 

41 number_of_events: int # Count 16 (unitless) 

42 average_event_duration: int # Time Second 16 (seconds, integer) 

43 time_elapsed_since_last_event: float # Time Exponential 8 (seconds) 

44 sensing_duration: float # Time Exponential 8 (seconds) 

45 

46 def __post_init__(self) -> None: 

47 """Validate data fields.""" 

48 if not 0 <= self.number_of_events <= UINT16_MAX: 

49 raise ValueError(f"Number of events {self.number_of_events} is outside valid range (0 to {UINT16_MAX})") 

50 if not 0 <= self.average_event_duration <= UINT16_MAX: 

51 raise ValueError( 

52 f"Average event duration {self.average_event_duration} s is outside valid range (0 to {UINT16_MAX})" 

53 ) 

54 if self.time_elapsed_since_last_event < 0.0: 

55 raise ValueError(f"Time elapsed {self.time_elapsed_since_last_event} s cannot be negative") 

56 if self.sensing_duration < 0.0: 

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

58 

59 

60class EventStatisticsCharacteristic(BaseCharacteristic[EventStatisticsData]): 

61 """Event Statistics characteristic (0x2AF4). 

62 

63 org.bluetooth.characteristic.event_statistics 

64 

65 Statistics for events: count (uint16), average duration (uint16, 1 s), 

66 time since last event (Time Exponential 8), sensing duration 

67 (Time Exponential 8). 

68 """ 

69 

70 expected_length: int = 6 # 2 x uint16 + 2 x uint8 

71 min_length: int = 6 

72 expected_type = EventStatisticsData 

73 

74 def _decode_value( 

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

76 ) -> EventStatisticsData: 

77 """Parse event statistics. 

78 

79 Args: 

80 data: Raw bytes (6 bytes). 

81 ctx: Optional CharacteristicContext. 

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

83 

84 Returns: 

85 EventStatisticsData. 

86 

87 """ 

88 count = DataParser.parse_int16(data, 0, signed=False) 

89 avg_dur = DataParser.parse_int16(data, 2, signed=False) 

90 elapsed_raw = DataParser.parse_int8(data, 4, signed=False) 

91 duration_raw = DataParser.parse_int8(data, 5, signed=False) 

92 

93 return EventStatisticsData( 

94 number_of_events=count, 

95 average_event_duration=avg_dur, 

96 time_elapsed_since_last_event=_decode_time_exponential(elapsed_raw), 

97 sensing_duration=_decode_time_exponential(duration_raw), 

98 ) 

99 

100 def _encode_value(self, data: EventStatisticsData) -> bytearray: 

101 """Encode event statistics. 

102 

103 Args: 

104 data: EventStatisticsData instance. 

105 

106 Returns: 

107 Encoded bytes (6 bytes). 

108 

109 """ 

110 elapsed_raw = _encode_time_exponential(data.time_elapsed_since_last_event) 

111 duration_raw = _encode_time_exponential(data.sensing_duration) 

112 

113 if not 0 <= data.number_of_events <= UINT16_MAX: 

114 raise ValueError(f"Event count {data.number_of_events} exceeds uint16 range") 

115 if not 0 <= data.average_event_duration <= UINT16_MAX: 

116 raise ValueError(f"Average duration {data.average_event_duration} exceeds uint16 range") 

117 

118 result = bytearray() 

119 result.extend(DataParser.encode_int16(data.number_of_events, signed=False)) 

120 result.extend(DataParser.encode_int16(data.average_event_duration, signed=False)) 

121 result.extend(DataParser.encode_int8(elapsed_raw, signed=False)) 

122 result.extend(DataParser.encode_int8(duration_raw, signed=False)) 

123 return result