Coverage for src / bluetooth_sig / gatt / characteristics / battery_information.py: 98%

135 statements  

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

1"""Battery Information characteristic implementation. 

2 

3Implements the Battery Information characteristic (0x2BEC) from the Battery 

4Service. A 16-bit flags field controls the presence of optional fields. 

5A mandatory Battery Features byte is always present after the flags. 

6 

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

8 

9References: 

10 Bluetooth SIG Battery Service 1.1 

11 org.bluetooth.characteristic.battery_information (GSS YAML) 

12""" 

13 

14from __future__ import annotations 

15 

16from enum import IntEnum, IntFlag 

17 

18import msgspec 

19 

20from ..constants import UINT8_MAX, UINT24_MAX 

21from ..context import CharacteristicContext 

22from .base import BaseCharacteristic 

23from .utils import DataParser, IEEE11073Parser 

24 

25 

26class BatteryInformationFlags(IntFlag): 

27 """Battery Information flags as per Bluetooth SIG specification.""" 

28 

29 MANUFACTURE_DATE_PRESENT = 0x0001 

30 EXPIRATION_DATE_PRESENT = 0x0002 

31 DESIGNED_CAPACITY_PRESENT = 0x0004 

32 LOW_ENERGY_PRESENT = 0x0008 

33 CRITICAL_ENERGY_PRESENT = 0x0010 

34 BATTERY_CHEMISTRY_PRESENT = 0x0020 

35 NOMINAL_VOLTAGE_PRESENT = 0x0040 

36 AGGREGATION_GROUP_PRESENT = 0x0080 

37 

38 

39class BatteryFeatures(IntFlag): 

40 """Battery Features bitfield as per Bluetooth SIG specification.""" 

41 

42 REPLACEABLE = 0x01 

43 RECHARGEABLE = 0x02 

44 

45 

46class BatteryChemistry(IntEnum): 

47 """Battery Chemistry enumeration as per Bluetooth SIG specification.""" 

48 

49 UNKNOWN = 0 

50 ALKALINE = 1 

51 LEAD_ACID = 2 

52 LITHIUM_IRON_DISULFIDE = 3 

53 LITHIUM_MANGANESE_DIOXIDE = 4 

54 LITHIUM_ION = 5 

55 LITHIUM_POLYMER = 6 

56 NICKEL_OXYHYDROXIDE = 7 

57 NICKEL_CADMIUM = 8 

58 NICKEL_METAL_HYDRIDE = 9 

59 SILVER_OXIDE = 10 

60 ZINC_CHLORIDE = 11 

61 ZINC_AIR = 12 

62 ZINC_CARBON = 13 

63 OTHER = 255 

64 

65 

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

67 """Parsed data from Battery Information characteristic. 

68 

69 Attributes: 

70 flags: Raw 16-bit flags field. 

71 battery_features: Mandatory features bitfield (replaceable, 

72 rechargeable). 

73 battery_manufacture_date: Days since epoch (1970-01-01). 

74 None if absent. 

75 battery_expiration_date: Days since epoch (1970-01-01). 

76 None if absent. 

77 battery_designed_capacity: Designed capacity in kWh (medfloat16). 

78 None if absent. 

79 battery_low_energy: Low energy threshold in kWh (medfloat16). 

80 None if absent. 

81 battery_critical_energy: Critical energy threshold in kWh 

82 (medfloat16). None if absent. 

83 battery_chemistry: Chemistry type. None if absent. 

84 nominal_voltage: Nominal voltage in volts (medfloat16). 

85 None if absent. 

86 battery_aggregation_group: Aggregation group number (0=none, 

87 1-254=group). None if absent. 

88 

89 """ 

90 

91 flags: BatteryInformationFlags 

92 battery_features: BatteryFeatures 

93 battery_manufacture_date: int | None = None 

94 battery_expiration_date: int | None = None 

95 battery_designed_capacity: float | None = None 

96 battery_low_energy: float | None = None 

97 battery_critical_energy: float | None = None 

98 battery_chemistry: BatteryChemistry | None = None 

99 nominal_voltage: float | None = None 

100 battery_aggregation_group: int | None = None 

101 

102 def __post_init__(self) -> None: 

103 """Validate field ranges.""" 

104 if self.battery_manufacture_date is not None and not 0 <= self.battery_manufacture_date <= UINT24_MAX: 

105 raise ValueError(f"Manufacture date must be 0-{UINT24_MAX}, got {self.battery_manufacture_date}") 

106 if self.battery_expiration_date is not None and not 0 <= self.battery_expiration_date <= UINT24_MAX: 

107 raise ValueError(f"Expiration date must be 0-{UINT24_MAX}, got {self.battery_expiration_date}") 

108 if self.battery_aggregation_group is not None and not 0 <= self.battery_aggregation_group <= UINT8_MAX: 

109 raise ValueError(f"Aggregation group must be 0-{UINT8_MAX}, got {self.battery_aggregation_group}") 

110 

111 

112class BatteryInformationCharacteristic( 

113 BaseCharacteristic[BatteryInformation], 

114): 

115 """Battery Information characteristic (0x2BEC). 

116 

117 Reports physical battery characteristics including features, dates, 

118 capacity, chemistry, voltage, and aggregation group. 

119 

120 Flag-bit assignments (from GSS YAML, 16-bit flags): 

121 Bit 0: Battery Manufacture Date Present 

122 Bit 1: Battery Expiration Date Present 

123 Bit 2: Battery Designed Capacity Present 

124 Bit 3: Battery Low Energy Present 

125 Bit 4: Battery Critical Energy Present 

126 Bit 5: Battery Chemistry Present 

127 Bit 6: Nominal Voltage Present 

128 Bit 7: Battery Aggregation Group Present 

129 Bits 8-15: Reserved for Future Use 

130 

131 The mandatory Battery Features byte is always present after the flags. 

132 

133 """ 

134 

135 expected_type = BatteryInformation 

136 min_length: int = 3 # 2 bytes flags + 1 byte mandatory features 

137 allow_variable_length: bool = True 

138 

139 def _decode_value( 

140 self, 

141 data: bytearray, 

142 ctx: CharacteristicContext | None = None, 

143 *, 

144 validate: bool = True, 

145 ) -> BatteryInformation: 

146 """Parse Battery Information from raw BLE bytes. 

147 

148 Args: 

149 data: Raw bytearray from BLE characteristic. 

150 ctx: Optional context (unused). 

151 validate: Whether to validate ranges. 

152 

153 Returns: 

154 BatteryInformation with all present fields populated. 

155 

156 """ 

157 flags = BatteryInformationFlags(DataParser.parse_int16(data, 0, signed=False)) 

158 battery_features = BatteryFeatures(DataParser.parse_int8(data, 2, signed=False)) 

159 offset = 3 

160 

161 # Bit 0 -- Battery Manufacture Date (uint24, days since epoch) 

162 manufacture_date = None 

163 if flags & BatteryInformationFlags.MANUFACTURE_DATE_PRESENT: 

164 manufacture_date = DataParser.parse_int24(data, offset, signed=False) 

165 offset += 3 

166 

167 # Bit 1 -- Battery Expiration Date (uint24, days since epoch) 

168 expiration_date = None 

169 if flags & BatteryInformationFlags.EXPIRATION_DATE_PRESENT: 

170 expiration_date = DataParser.parse_int24(data, offset, signed=False) 

171 offset += 3 

172 

173 # Bit 2 -- Battery Designed Capacity (medfloat16, kWh) 

174 designed_capacity = None 

175 if flags & BatteryInformationFlags.DESIGNED_CAPACITY_PRESENT: 

176 designed_capacity = IEEE11073Parser.parse_sfloat(data, offset) 

177 offset += 2 

178 

179 # Bit 3 -- Battery Low Energy (medfloat16, kWh) 

180 low_energy = None 

181 if flags & BatteryInformationFlags.LOW_ENERGY_PRESENT: 

182 low_energy = IEEE11073Parser.parse_sfloat(data, offset) 

183 offset += 2 

184 

185 # Bit 4 -- Battery Critical Energy (medfloat16, kWh) 

186 critical_energy = None 

187 if flags & BatteryInformationFlags.CRITICAL_ENERGY_PRESENT: 

188 critical_energy = IEEE11073Parser.parse_sfloat(data, offset) 

189 offset += 2 

190 

191 # Bit 5 -- Battery Chemistry (uint8 enum) 

192 chemistry = None 

193 if flags & BatteryInformationFlags.BATTERY_CHEMISTRY_PRESENT: 

194 raw_chem = DataParser.parse_int8(data, offset, signed=False) 

195 try: 

196 chemistry = BatteryChemistry(raw_chem) 

197 except ValueError: 

198 chemistry = BatteryChemistry.UNKNOWN 

199 offset += 1 

200 

201 # Bit 6 -- Nominal Voltage (medfloat16, volts) 

202 nominal_voltage = None 

203 if flags & BatteryInformationFlags.NOMINAL_VOLTAGE_PRESENT: 

204 nominal_voltage = IEEE11073Parser.parse_sfloat(data, offset) 

205 offset += 2 

206 

207 # Bit 7 -- Battery Aggregation Group (uint8) 

208 aggregation_group = None 

209 if flags & BatteryInformationFlags.AGGREGATION_GROUP_PRESENT: 

210 aggregation_group = DataParser.parse_int8(data, offset, signed=False) 

211 offset += 1 

212 

213 return BatteryInformation( 

214 flags=flags, 

215 battery_features=battery_features, 

216 battery_manufacture_date=manufacture_date, 

217 battery_expiration_date=expiration_date, 

218 battery_designed_capacity=designed_capacity, 

219 battery_low_energy=low_energy, 

220 battery_critical_energy=critical_energy, 

221 battery_chemistry=chemistry, 

222 nominal_voltage=nominal_voltage, 

223 battery_aggregation_group=aggregation_group, 

224 ) 

225 

226 def _encode_value(self, data: BatteryInformation) -> bytearray: 

227 """Encode BatteryInformation back to BLE bytes. 

228 

229 Args: 

230 data: BatteryInformation instance. 

231 

232 Returns: 

233 Encoded bytearray matching the BLE wire format. 

234 

235 """ 

236 flags = BatteryInformationFlags(0) 

237 

238 if data.battery_manufacture_date is not None: 

239 flags |= BatteryInformationFlags.MANUFACTURE_DATE_PRESENT 

240 if data.battery_expiration_date is not None: 

241 flags |= BatteryInformationFlags.EXPIRATION_DATE_PRESENT 

242 if data.battery_designed_capacity is not None: 

243 flags |= BatteryInformationFlags.DESIGNED_CAPACITY_PRESENT 

244 if data.battery_low_energy is not None: 

245 flags |= BatteryInformationFlags.LOW_ENERGY_PRESENT 

246 if data.battery_critical_energy is not None: 

247 flags |= BatteryInformationFlags.CRITICAL_ENERGY_PRESENT 

248 if data.battery_chemistry is not None: 

249 flags |= BatteryInformationFlags.BATTERY_CHEMISTRY_PRESENT 

250 if data.nominal_voltage is not None: 

251 flags |= BatteryInformationFlags.NOMINAL_VOLTAGE_PRESENT 

252 if data.battery_aggregation_group is not None: 

253 flags |= BatteryInformationFlags.AGGREGATION_GROUP_PRESENT 

254 

255 result = DataParser.encode_int16(int(flags), signed=False) 

256 result.extend(DataParser.encode_int8(int(data.battery_features), signed=False)) 

257 

258 if data.battery_manufacture_date is not None: 

259 result.extend(DataParser.encode_int24(data.battery_manufacture_date, signed=False)) 

260 if data.battery_expiration_date is not None: 

261 result.extend(DataParser.encode_int24(data.battery_expiration_date, signed=False)) 

262 if data.battery_designed_capacity is not None: 

263 result.extend(IEEE11073Parser.encode_sfloat(data.battery_designed_capacity)) 

264 if data.battery_low_energy is not None: 

265 result.extend(IEEE11073Parser.encode_sfloat(data.battery_low_energy)) 

266 if data.battery_critical_energy is not None: 

267 result.extend(IEEE11073Parser.encode_sfloat(data.battery_critical_energy)) 

268 if data.battery_chemistry is not None: 

269 result.extend(DataParser.encode_int8(int(data.battery_chemistry), signed=False)) 

270 if data.nominal_voltage is not None: 

271 result.extend(IEEE11073Parser.encode_sfloat(data.nominal_voltage)) 

272 if data.battery_aggregation_group is not None: 

273 result.extend(DataParser.encode_int8(data.battery_aggregation_group, signed=False)) 

274 

275 return result