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

63 statements  

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

1"""Battery Time Status characteristic implementation. 

2 

3Implements the Battery Time Status characteristic (0x2BEE) from the Battery 

4Service. An 8-bit flags field controls the presence of optional time fields. 

5 

6All flag bits use **normal logic** (1 = present, 0 = absent). 

7 

8The mandatory "Time until Discharged" field and both optional time fields 

9use uint24 in **minutes**. Two sentinel values are defined: 

10 - 0xFFFFFF: Unknown 

11 - 0xFFFFFE: Greater than 0xFFFFFD 

12 

13References: 

14 Bluetooth SIG Battery Service 1.1 

15 org.bluetooth.characteristic.battery_time_status (GSS YAML) 

16""" 

17 

18from __future__ import annotations 

19 

20from enum import IntFlag 

21 

22import msgspec 

23 

24from ..constants import UINT24_MAX 

25from ..context import CharacteristicContext 

26from .base import BaseCharacteristic 

27from .utils import DataParser 

28 

29# Sentinel values for time fields (uint24 minutes) 

30_TIME_UNKNOWN: int = 0xFFFFFF 

31_TIME_OVERFLOW: int = 0xFFFFFE 

32 

33 

34class BatteryTimeStatusFlags(IntFlag): 

35 """Battery Time Status flags as per Bluetooth SIG specification.""" 

36 

37 DISCHARGED_ON_STANDBY_PRESENT = 0x01 

38 RECHARGED_PRESENT = 0x02 

39 

40 

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

42 """Parsed data from Battery Time Status characteristic. 

43 

44 Attributes: 

45 flags: Raw 8-bit flags field. 

46 time_until_discharged: Estimated minutes until discharged. 

47 None if raw == 0xFFFFFF (Unknown). 

48 time_until_discharged_on_standby: Minutes until discharged on standby. 

49 None if absent or raw == 0xFFFFFF (Unknown). 

50 time_until_recharged: Minutes until recharged. 

51 None if absent or raw == 0xFFFFFF (Unknown). 

52 

53 """ 

54 

55 flags: BatteryTimeStatusFlags 

56 time_until_discharged: int | None = None 

57 time_until_discharged_on_standby: int | None = None 

58 time_until_recharged: int | None = None 

59 

60 def __post_init__(self) -> None: 

61 """Validate field ranges.""" 

62 if self.time_until_discharged is not None and not 0 <= self.time_until_discharged <= UINT24_MAX: 

63 raise ValueError(f"Time until discharged must be 0-{UINT24_MAX}, got {self.time_until_discharged}") 

64 if ( 

65 self.time_until_discharged_on_standby is not None 

66 and not 0 <= self.time_until_discharged_on_standby <= UINT24_MAX 

67 ): 

68 raise ValueError( 

69 f"Time until discharged on standby must be 0-{UINT24_MAX}, got {self.time_until_discharged_on_standby}" 

70 ) 

71 if self.time_until_recharged is not None and not 0 <= self.time_until_recharged <= UINT24_MAX: 

72 raise ValueError(f"Time until recharged must be 0-{UINT24_MAX}, got {self.time_until_recharged}") 

73 

74 

75def _decode_time_minutes(data: bytearray, offset: int) -> tuple[int | None, int]: 

76 """Decode a uint24 time field in minutes with sentinel handling. 

77 

78 Args: 

79 data: Raw BLE bytes. 

80 offset: Current read position. 

81 

82 Returns: 

83 (value_or_none, new_offset). Returns None for the 0xFFFFFF sentinel. 

84 

85 """ 

86 if len(data) < offset + 3: 

87 return None, offset 

88 raw = DataParser.parse_int24(data, offset, signed=False) 

89 if raw == _TIME_UNKNOWN: 

90 return None, offset + 3 

91 return raw, offset + 3 

92 

93 

94def _encode_time_minutes(value: int | None) -> bytearray: 

95 """Encode a time-in-minutes field to uint24, using sentinel for None. 

96 

97 Args: 

98 value: Minutes value, or None for Unknown. 

99 

100 Returns: 

101 3-byte encoded value. 

102 

103 """ 

104 if value is None: 

105 return DataParser.encode_int24(_TIME_UNKNOWN, signed=False) 

106 return DataParser.encode_int24(value, signed=False) 

107 

108 

109class BatteryTimeStatusCharacteristic(BaseCharacteristic[BatteryTimeStatus]): 

110 """Battery Time Status characteristic (0x2BEE). 

111 

112 Reports estimated times for battery discharge and recharge. 

113 

114 Flag-bit assignments (from GSS YAML): 

115 Bit 0: Time until Discharged on Standby present 

116 Bit 1: Time until Recharged present 

117 Bits 2-7: Reserved for Future Use 

118 

119 The mandatory "Time until Discharged" field is always present after flags. 

120 

121 """ 

122 

123 expected_type = BatteryTimeStatus 

124 min_length: int = 4 # 1 byte flags + 3 bytes mandatory time field 

125 allow_variable_length: bool = True 

126 

127 def _decode_value( 

128 self, 

129 data: bytearray, 

130 ctx: CharacteristicContext | None = None, 

131 *, 

132 validate: bool = True, 

133 ) -> BatteryTimeStatus: 

134 """Parse Battery Time Status from raw BLE bytes. 

135 

136 Args: 

137 data: Raw bytearray from BLE characteristic. 

138 ctx: Optional context (unused). 

139 validate: Whether to validate ranges. 

140 

141 Returns: 

142 BatteryTimeStatus with all present fields populated. 

143 

144 """ 

145 flags = BatteryTimeStatusFlags(DataParser.parse_int8(data, 0, signed=False)) 

146 offset = 1 

147 

148 # Mandatory: Time until Discharged (uint24, minutes) 

149 time_until_discharged, offset = _decode_time_minutes(data, offset) 

150 

151 # Bit 0 -- Time until Discharged on Standby 

152 time_until_discharged_on_standby = None 

153 if flags & BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT: 

154 time_until_discharged_on_standby, offset = _decode_time_minutes(data, offset) 

155 

156 # Bit 1 -- Time until Recharged 

157 time_until_recharged = None 

158 if flags & BatteryTimeStatusFlags.RECHARGED_PRESENT: 

159 time_until_recharged, offset = _decode_time_minutes(data, offset) 

160 

161 return BatteryTimeStatus( 

162 flags=flags, 

163 time_until_discharged=time_until_discharged, 

164 time_until_discharged_on_standby=time_until_discharged_on_standby, 

165 time_until_recharged=time_until_recharged, 

166 ) 

167 

168 def _encode_value(self, data: BatteryTimeStatus) -> bytearray: 

169 """Encode BatteryTimeStatus back to BLE bytes. 

170 

171 Args: 

172 data: BatteryTimeStatus instance. 

173 

174 Returns: 

175 Encoded bytearray matching the BLE wire format. 

176 

177 """ 

178 flags = BatteryTimeStatusFlags(0) 

179 

180 if data.time_until_discharged_on_standby is not None: 

181 flags |= BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT 

182 if data.time_until_recharged is not None: 

183 flags |= BatteryTimeStatusFlags.RECHARGED_PRESENT 

184 

185 result = DataParser.encode_int8(int(flags), signed=False) 

186 

187 # Mandatory: Time until Discharged 

188 result.extend(_encode_time_minutes(data.time_until_discharged)) 

189 

190 if data.time_until_discharged_on_standby is not None: 

191 result.extend(_encode_time_minutes(data.time_until_discharged_on_standby)) 

192 if data.time_until_recharged is not None: 

193 result.extend(_encode_time_minutes(data.time_until_recharged)) 

194 

195 return result