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

74 statements  

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

1"""Battery Health Status characteristic implementation. 

2 

3Implements the Battery Health Status characteristic (0x2BEA) from the Battery 

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

5 

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

7 

8References: 

9 Bluetooth SIG Battery Service 1.1 

10 org.bluetooth.characteristic.battery_health_status (GSS YAML) 

11""" 

12 

13from __future__ import annotations 

14 

15from enum import IntFlag 

16 

17import msgspec 

18 

19from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX 

20from ..context import CharacteristicContext 

21from .base import BaseCharacteristic 

22from .utils import DataParser 

23 

24# Maximum health summary percentage 

25_HEALTH_SUMMARY_MAX: int = 100 

26 

27# Temperature sentinels (sint8) 

28_TEMP_GREATER_THAN_126: int = 0x7F # raw == 127 means ">126" 

29_TEMP_LESS_THAN_MINUS_127: int = -128 # raw == -128 (0x80) means "<-127" 

30 

31 

32class BatteryHealthStatusFlags(IntFlag): 

33 """Battery Health Status flags as per Bluetooth SIG specification.""" 

34 

35 HEALTH_SUMMARY_PRESENT = 0x01 

36 CYCLE_COUNT_PRESENT = 0x02 

37 CURRENT_TEMPERATURE_PRESENT = 0x04 

38 DEEP_DISCHARGE_COUNT_PRESENT = 0x08 

39 

40 

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

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

43 

44 Attributes: 

45 flags: Raw 8-bit flags field. 

46 battery_health_summary: Percentage 0-100 representing overall health. 

47 None if absent. 

48 cycle_count: Number of charge cycles. None if absent. 

49 current_temperature: Current temperature in degrees Celsius. 

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

51 deep_discharge_count: Number of complete discharges. None if absent. 

52 

53 """ 

54 

55 flags: BatteryHealthStatusFlags 

56 battery_health_summary: int | None = None 

57 cycle_count: int | None = None 

58 current_temperature: int | None = None 

59 deep_discharge_count: int | None = None 

60 

61 def __post_init__(self) -> None: 

62 """Validate field ranges.""" 

63 if self.battery_health_summary is not None and not 0 <= self.battery_health_summary <= _HEALTH_SUMMARY_MAX: 

64 raise ValueError( 

65 f"Battery health summary must be 0-{_HEALTH_SUMMARY_MAX}, got {self.battery_health_summary}" 

66 ) 

67 if self.cycle_count is not None and not 0 <= self.cycle_count <= UINT16_MAX: 

68 raise ValueError(f"Cycle count must be 0-{UINT16_MAX}, got {self.cycle_count}") 

69 if self.current_temperature is not None and not SINT8_MIN <= self.current_temperature <= SINT8_MAX: 

70 raise ValueError(f"Temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.current_temperature}") 

71 if self.deep_discharge_count is not None and not 0 <= self.deep_discharge_count <= UINT16_MAX: 

72 raise ValueError(f"Deep discharge count must be 0-{UINT16_MAX}, got {self.deep_discharge_count}") 

73 

74 

75class BatteryHealthStatusCharacteristic(BaseCharacteristic[BatteryHealthStatus]): 

76 """Battery Health Status characteristic (0x2BEA). 

77 

78 Reports battery health information including summary percentage, cycle 

79 count, temperature, and deep discharge count. 

80 

81 Flag-bit assignments (from GSS YAML): 

82 Bit 0: Battery Health Summary Present 

83 Bit 1: Cycle Count Present 

84 Bit 2: Current Temperature Present 

85 Bit 3: Deep Discharge Count Present 

86 Bits 4-7: Reserved for Future Use 

87 

88 """ 

89 

90 expected_type = BatteryHealthStatus 

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

92 allow_variable_length: bool = True 

93 

94 def _decode_value( 

95 self, 

96 data: bytearray, 

97 ctx: CharacteristicContext | None = None, 

98 *, 

99 validate: bool = True, 

100 ) -> BatteryHealthStatus: 

101 """Parse Battery Health Status from raw BLE bytes. 

102 

103 Args: 

104 data: Raw bytearray from BLE characteristic. 

105 ctx: Optional context (unused). 

106 validate: Whether to validate ranges. 

107 

108 Returns: 

109 BatteryHealthStatus with all present fields populated. 

110 

111 """ 

112 flags = BatteryHealthStatusFlags(DataParser.parse_int8(data, 0, signed=False)) 

113 offset = 1 

114 

115 # Bit 0 -- Battery Health Summary (uint8, percentage) 

116 battery_health_summary = None 

117 if flags & BatteryHealthStatusFlags.HEALTH_SUMMARY_PRESENT: 

118 battery_health_summary = DataParser.parse_int8(data, offset, signed=False) 

119 offset += 1 

120 

121 # Bit 1 -- Cycle Count (uint16) 

122 cycle_count = None 

123 if flags & BatteryHealthStatusFlags.CYCLE_COUNT_PRESENT: 

124 cycle_count = DataParser.parse_int16(data, offset, signed=False) 

125 offset += 2 

126 

127 # Bit 2 -- Current Temperature (sint8) 

128 current_temperature = None 

129 if flags & BatteryHealthStatusFlags.CURRENT_TEMPERATURE_PRESENT: 

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

131 offset += 1 

132 

133 # Bit 3 -- Deep Discharge Count (uint16) 

134 deep_discharge_count = None 

135 if flags & BatteryHealthStatusFlags.DEEP_DISCHARGE_COUNT_PRESENT: 

136 deep_discharge_count = DataParser.parse_int16(data, offset, signed=False) 

137 offset += 2 

138 

139 return BatteryHealthStatus( 

140 flags=flags, 

141 battery_health_summary=battery_health_summary, 

142 cycle_count=cycle_count, 

143 current_temperature=current_temperature, 

144 deep_discharge_count=deep_discharge_count, 

145 ) 

146 

147 def _encode_value(self, data: BatteryHealthStatus) -> bytearray: 

148 """Encode BatteryHealthStatus back to BLE bytes. 

149 

150 Args: 

151 data: BatteryHealthStatus instance. 

152 

153 Returns: 

154 Encoded bytearray matching the BLE wire format. 

155 

156 """ 

157 flags = BatteryHealthStatusFlags(0) 

158 

159 if data.battery_health_summary is not None: 

160 flags |= BatteryHealthStatusFlags.HEALTH_SUMMARY_PRESENT 

161 if data.cycle_count is not None: 

162 flags |= BatteryHealthStatusFlags.CYCLE_COUNT_PRESENT 

163 if data.current_temperature is not None: 

164 flags |= BatteryHealthStatusFlags.CURRENT_TEMPERATURE_PRESENT 

165 if data.deep_discharge_count is not None: 

166 flags |= BatteryHealthStatusFlags.DEEP_DISCHARGE_COUNT_PRESENT 

167 

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

169 

170 if data.battery_health_summary is not None: 

171 result.extend(DataParser.encode_int8(data.battery_health_summary, signed=False)) 

172 if data.cycle_count is not None: 

173 result.extend(DataParser.encode_int16(data.cycle_count, signed=False)) 

174 if data.current_temperature is not None: 

175 result.extend(DataParser.encode_int8(data.current_temperature, signed=True)) 

176 if data.deep_discharge_count is not None: 

177 result.extend(DataParser.encode_int16(data.deep_discharge_count, signed=False)) 

178 

179 return result