Coverage for src / bluetooth_sig / gatt / characteristics / battery_health_information.py: 96%

56 statements  

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

1"""Battery Health Information characteristic implementation. 

2 

3Implements the Battery Health Information characteristic (0x2BEB) from the 

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

5 

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

7 

8Bit 1 gates two fields simultaneously: Min and Max Designed Operating 

9Temperature. 

10 

11References: 

12 Bluetooth SIG Battery Service 1.1 

13 org.bluetooth.characteristic.battery_health_information (GSS YAML) 

14""" 

15 

16from __future__ import annotations 

17 

18from enum import IntFlag 

19 

20import msgspec 

21 

22from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX 

23from ..context import CharacteristicContext 

24from .base import BaseCharacteristic 

25from .utils import DataParser 

26 

27 

28class BatteryHealthInformationFlags(IntFlag): 

29 """Battery Health Information flags as per Bluetooth SIG specification.""" 

30 

31 CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT = 0x01 

32 TEMPERATURE_RANGE_PRESENT = 0x02 

33 

34 

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

36 """Parsed data from Battery Health Information characteristic. 

37 

38 Attributes: 

39 flags: Raw 8-bit flags field. 

40 cycle_count_designed_lifetime: Designed number of charge cycles. 

41 None if absent. 

42 min_designed_operating_temperature: Min operating temperature (C). 

43 127 means ">126", -128 means "<-127". None if absent. 

44 max_designed_operating_temperature: Max operating temperature (C). 

45 127 means ">126", -128 means "<-127". None if absent. 

46 

47 """ 

48 

49 flags: BatteryHealthInformationFlags 

50 cycle_count_designed_lifetime: int | None = None 

51 min_designed_operating_temperature: int | None = None 

52 max_designed_operating_temperature: int | None = None 

53 

54 def __post_init__(self) -> None: 

55 """Validate field ranges.""" 

56 if self.cycle_count_designed_lifetime is not None and not 0 <= self.cycle_count_designed_lifetime <= UINT16_MAX: 

57 raise ValueError( 

58 f"Cycle count designed lifetime must be 0-{UINT16_MAX}, got {self.cycle_count_designed_lifetime}" 

59 ) 

60 if ( 

61 self.min_designed_operating_temperature is not None 

62 and not SINT8_MIN <= self.min_designed_operating_temperature <= SINT8_MAX 

63 ): 

64 raise ValueError( 

65 f"Min temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.min_designed_operating_temperature}" 

66 ) 

67 if ( 

68 self.max_designed_operating_temperature is not None 

69 and not SINT8_MIN <= self.max_designed_operating_temperature <= SINT8_MAX 

70 ): 

71 raise ValueError( 

72 f"Max temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.max_designed_operating_temperature}" 

73 ) 

74 

75 

76class BatteryHealthInformationCharacteristic( 

77 BaseCharacteristic[BatteryHealthInformation], 

78): 

79 """Battery Health Information characteristic (0x2BEB). 

80 

81 Reports designed battery health parameters including designed cycle count 

82 and designed operating temperature range. 

83 

84 Flag-bit assignments (from GSS YAML): 

85 Bit 0: Cycle Count Designed Lifetime Present 

86 Bit 1: Min and Max Designed Operating Temperature Present 

87 Bits 2-7: Reserved for Future Use 

88 

89 Note: Bit 1 gates two fields (min + max temperature) simultaneously. 

90 

91 """ 

92 

93 expected_type = BatteryHealthInformation 

94 min_length: int = 1 # 1 byte flags only (all fields optional) 

95 allow_variable_length: bool = True 

96 

97 def _decode_value( 

98 self, 

99 data: bytearray, 

100 ctx: CharacteristicContext | None = None, 

101 *, 

102 validate: bool = True, 

103 ) -> BatteryHealthInformation: 

104 """Parse Battery Health Information from raw BLE bytes. 

105 

106 Args: 

107 data: Raw bytearray from BLE characteristic. 

108 ctx: Optional context (unused). 

109 validate: Whether to validate ranges. 

110 

111 Returns: 

112 BatteryHealthInformation with all present fields populated. 

113 

114 """ 

115 flags = BatteryHealthInformationFlags(DataParser.parse_int8(data, 0, signed=False)) 

116 offset = 1 

117 

118 # Bit 0 -- Cycle Count Designed Lifetime (uint16) 

119 cycle_count_designed_lifetime = None 

120 if flags & BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT: 

121 cycle_count_designed_lifetime = DataParser.parse_int16(data, offset, signed=False) 

122 offset += 2 

123 

124 # Bit 1 -- Min AND Max Designed Operating Temperature (2 x sint8) 

125 min_temp = None 

126 max_temp = None 

127 if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT: 

128 min_temp = DataParser.parse_int8(data, offset, signed=True) 

129 offset += 1 

130 max_temp = DataParser.parse_int8(data, offset, signed=True) 

131 offset += 1 

132 

133 return BatteryHealthInformation( 

134 flags=flags, 

135 cycle_count_designed_lifetime=cycle_count_designed_lifetime, 

136 min_designed_operating_temperature=min_temp, 

137 max_designed_operating_temperature=max_temp, 

138 ) 

139 

140 def _encode_value(self, data: BatteryHealthInformation) -> bytearray: 

141 """Encode BatteryHealthInformation back to BLE bytes. 

142 

143 Args: 

144 data: BatteryHealthInformation instance. 

145 

146 Returns: 

147 Encoded bytearray matching the BLE wire format. 

148 

149 """ 

150 flags = BatteryHealthInformationFlags(0) 

151 

152 if data.cycle_count_designed_lifetime is not None: 

153 flags |= BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT 

154 if data.min_designed_operating_temperature is not None or data.max_designed_operating_temperature is not None: 

155 flags |= BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT 

156 

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

158 

159 if data.cycle_count_designed_lifetime is not None: 

160 result.extend(DataParser.encode_int16(data.cycle_count_designed_lifetime, signed=False)) 

161 if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT: 

162 min_temp = ( 

163 data.min_designed_operating_temperature if data.min_designed_operating_temperature is not None else 0 

164 ) 

165 max_temp = ( 

166 data.max_designed_operating_temperature if data.max_designed_operating_temperature is not None else 0 

167 ) 

168 result.extend(DataParser.encode_int8(min_temp, signed=True)) 

169 result.extend(DataParser.encode_int8(max_temp, signed=True)) 

170 

171 return result