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

162 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""Battery Level Status characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum 

6from typing import Any 

7 

8import msgspec 

9 

10from ...types.battery import BatteryChargeLevel, BatteryChargeState, BatteryChargingType, BatteryFaultReason 

11from ..constants import UINT8_MAX 

12from ..context import CharacteristicContext 

13from .base import BaseCharacteristic 

14from .utils import BitFieldUtils 

15 

16 

17# Bit position constants for Battery Power State characteristic 

18class BatteryPowerStateBits: # pylint: disable=too-few-public-methods 

19 """Bit positions used in Battery Power State characteristic parsing.""" 

20 

21 # Flags byte bit positions 

22 IDENTIFIER_PRESENT_BIT = 0 

23 BATTERY_LEVEL_PRESENT_BIT = 1 

24 ADDITIONAL_INFO_PRESENT_BIT = 2 

25 

26 # Basic state byte bit positions 

27 BATTERY_PRESENT_START_BIT = 0 

28 BATTERY_PRESENT_NUM_BITS = 2 

29 WIRED_POWER_CONNECTED_BIT = 2 

30 WIRELESS_POWER_CONNECTED_BIT = 3 

31 CHARGE_STATE_START_BIT = 4 

32 CHARGE_STATE_NUM_BITS = 2 

33 CHARGE_LEVEL_START_BIT = 6 

34 CHARGE_LEVEL_NUM_BITS = 2 

35 

36 # Extended state (16-bit) bit positions 

37 BATTERY_PRESENT_EXT_BIT = 0 

38 WIRED_POWER_EXT_START_BIT = 1 

39 WIRED_POWER_EXT_NUM_BITS = 2 

40 WIRELESS_POWER_EXT_START_BIT = 3 

41 WIRELESS_POWER_EXT_NUM_BITS = 2 

42 CHARGE_STATE_EXT_START_BIT = 5 

43 CHARGE_STATE_EXT_NUM_BITS = 2 

44 CHARGE_LEVEL_EXT_START_BIT = 7 

45 CHARGE_LEVEL_EXT_NUM_BITS = 2 

46 CHARGING_TYPE_START_BIT = 9 

47 CHARGING_TYPE_NUM_BITS = 3 

48 FAULT_BITS_START_BIT = 12 

49 FAULT_BITS_NUM_BITS = 3 

50 

51 # Fault sub-bits within fault field 

52 BATTERY_FAULT_BIT = 0 

53 EXTERNAL_POWER_FAULT_BIT = 1 

54 OTHER_FAULT_BIT = 2 

55 

56 # Second byte parsing (charging type + faults) 

57 CHARGING_TYPE_BYTE_START_BIT = 0 

58 CHARGING_TYPE_BYTE_NUM_BITS = 3 

59 FAULT_BYTE_START_BIT = 3 

60 FAULT_BYTE_NUM_BITS = 5 

61 

62 

63class BatteryPresentState(IntEnum): 

64 """Battery present state enumeration.""" 

65 

66 UNKNOWN = 0 

67 NOT_PRESENT = 1 

68 PRESENT = 2 

69 RESERVED = 3 

70 

71 def __str__(self) -> str: 

72 """Return a human-readable representation of the battery presence state.""" 

73 return {0: "unknown", 1: "not_present", 2: "present", 3: "reserved"}[self.value] 

74 

75 @classmethod 

76 def from_byte(cls, byte_val: int) -> BatteryPresentState: 

77 """Create enum from byte value with fallback.""" 

78 try: 

79 return cls(byte_val) 

80 except ValueError: 

81 return cls.UNKNOWN 

82 

83 

84class BatteryPowerStateData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes 

85 """Parsed data from Battery Power State characteristic.""" 

86 

87 raw_value: int 

88 battery_present: BatteryPresentState 

89 wired_external_power_connected: bool 

90 wireless_external_power_connected: bool 

91 battery_charge_state: BatteryChargeState 

92 battery_charge_level: BatteryChargeLevel 

93 battery_charging_type: BatteryChargingType 

94 charging_fault_reason: BatteryFaultReason | tuple[BatteryFaultReason, ...] | None = None 

95 

96 def __post_init__(self) -> None: 

97 """Validate battery power state data.""" 

98 if not 0 <= self.raw_value <= UINT8_MAX: 

99 raise ValueError(f"Raw value must be 0-UINT8_MAX, got {self.raw_value}") 

100 

101 

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

103 """Parsed battery power state components.""" 

104 

105 battery_present: BatteryPresentState 

106 wired_external_power_connected: bool 

107 wireless_external_power_connected: bool 

108 battery_charge_state: BatteryChargeState 

109 battery_charge_level: BatteryChargeLevel 

110 battery_charging_type: BatteryChargingType = BatteryChargingType.UNKNOWN 

111 charging_fault_reason: BatteryFaultReason | tuple[BatteryFaultReason, ...] | None = None 

112 

113 

114class BatteryPowerStateCharacteristic(BaseCharacteristic): 

115 """Battery Level Status characteristic (0x2BED). 

116 

117 This characteristic encodes battery presence, external power 

118 sources, charging state, charge level and optional extended charging 

119 information. 

120 """ 

121 

122 _characteristic_name: str | None = "Battery Level Status" 

123 

124 # YAML describes this as boolean[] which maps to 'string' in the registry; 

125 # decode_value returns a dict, but tests and registry expect the declared 

126 # value_type to be 'string'. Override to keep metadata consistent. 

127 _manual_value_type = "string" 

128 

129 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BatteryPowerStateData: 

130 """Parse the Battery Level Status value. 

131 

132 The characteristic supports a 1-byte basic format and a 2-byte 

133 extended format with charging type and fault codes in the second 

134 byte. If `data` is empty or None a ValueError is raised. 

135 

136 Args: 

137 data: Raw bytearray from BLE characteristic. 

138 ctx: Optional CharacteristicContext providing surrounding context (may be None). 

139 

140 Returns: 

141 BatteryPowerStateData containing parsed battery power state values. 

142 

143 """ 

144 # `ctx` is part of the public decode_value signature but unused in this 

145 # concrete implementation — mark it as used for linters. 

146 del ctx 

147 if not data or len(data) < 1: 

148 raise ValueError("Battery Level Status must be at least 1 byte") 

149 

150 # Single-byte basic state available in all variants 

151 state_raw = int(data[0]) 

152 

153 # Full SIG format: Flags (1 byte) + Power State (2 bytes little-endian) 

154 if len(data) >= 3: 

155 flags = int(data[0]) 

156 power_state_raw = int.from_bytes(data[1:3], byteorder="little", signed=False) 

157 

158 parsed = self._parse_power_state_16(power_state_raw) 

159 

160 # validate/advance optional fields indicated in Flags 

161 offset = 3 

162 # Identifier needs an explicit, specific error message in tests 

163 if BitFieldUtils.test_bit(flags, BatteryPowerStateBits.IDENTIFIER_PRESENT_BIT): 

164 if len(data) < offset + 2: 

165 raise ValueError("Identifier indicated by Flags but missing from payload") 

166 offset += 2 

167 

168 # Combine remaining optional field checks into one branch to keep 

169 # static analysis branch count down. 

170 remaining_needed = offset 

171 if BitFieldUtils.test_bit(flags, BatteryPowerStateBits.BATTERY_LEVEL_PRESENT_BIT): 

172 remaining_needed += 1 

173 if BitFieldUtils.test_bit(flags, BatteryPowerStateBits.ADDITIONAL_INFO_PRESENT_BIT): 

174 remaining_needed += 1 

175 if len(data) < remaining_needed: 

176 raise ValueError("Flags indicate additional fields are missing from payload") 

177 # We don't need to advance offset further here because we only 

178 # validate presence (values are not returned in canonical output). 

179 

180 return BatteryPowerStateData( 

181 raw_value=int(data[0]), 

182 battery_present=parsed.battery_present, 

183 wired_external_power_connected=parsed.wired_external_power_connected, 

184 wireless_external_power_connected=parsed.wireless_external_power_connected, 

185 battery_charge_state=parsed.battery_charge_state, 

186 battery_charge_level=parsed.battery_charge_level, 

187 battery_charging_type=parsed.battery_charging_type, 

188 charging_fault_reason=parsed.charging_fault_reason, 

189 ) 

190 

191 # Two-byte variant: first byte holds basic state, second byte encodes 

192 # charging type (bits 0-2) and fault bitmap (bits 3-7) 

193 charging_fault_reason: Any = None 

194 

195 if len(data) >= 2: 

196 basic = self._parse_basic_state(state_raw) 

197 

198 second = int(data[1]) 

199 

200 fault_raw = BitFieldUtils.extract_bit_field( 

201 second, 

202 BatteryPowerStateBits.FAULT_BYTE_START_BIT, 

203 BatteryPowerStateBits.FAULT_BYTE_NUM_BITS, 

204 ) 

205 if fault_raw != 0: 

206 fault_reasons: list[BatteryFaultReason] = [] 

207 if BitFieldUtils.test_bit(fault_raw, BatteryPowerStateBits.BATTERY_FAULT_BIT): 

208 fault_reasons.append(BatteryFaultReason.BATTERY_FAULT) 

209 if BitFieldUtils.test_bit(fault_raw, BatteryPowerStateBits.EXTERNAL_POWER_FAULT_BIT): 

210 fault_reasons.append(BatteryFaultReason.EXTERNAL_POWER_FAULT) 

211 if BitFieldUtils.test_bit(fault_raw, BatteryPowerStateBits.OTHER_FAULT_BIT): 

212 fault_reasons.append(BatteryFaultReason.OTHER_FAULT) 

213 charging_fault_reason = ( 

214 fault_reasons[0] if len(fault_reasons) == 1 else tuple(fault_reasons) if fault_reasons else None 

215 ) 

216 

217 return BatteryPowerStateData( 

218 raw_value=state_raw, 

219 battery_present=basic.battery_present, 

220 wired_external_power_connected=basic.wired_external_power_connected, 

221 wireless_external_power_connected=basic.wireless_external_power_connected, 

222 battery_charge_state=basic.battery_charge_state, 

223 battery_charge_level=basic.battery_charge_level, 

224 battery_charging_type=BatteryChargingType.from_byte( 

225 BitFieldUtils.extract_bit_field( 

226 second, 

227 BatteryPowerStateBits.CHARGING_TYPE_BYTE_START_BIT, 

228 BatteryPowerStateBits.CHARGING_TYPE_BYTE_NUM_BITS, 

229 ) 

230 ), 

231 charging_fault_reason=charging_fault_reason, 

232 ) 

233 

234 # Single-byte basic variant 

235 basic = self._parse_basic_state(state_raw) 

236 return BatteryPowerStateData( 

237 raw_value=state_raw, 

238 battery_present=basic.battery_present, 

239 wired_external_power_connected=basic.wired_external_power_connected, 

240 wireless_external_power_connected=basic.wireless_external_power_connected, 

241 battery_charge_state=basic.battery_charge_state, 

242 battery_charge_level=basic.battery_charge_level, 

243 battery_charging_type=BatteryChargingType.UNKNOWN, 

244 charging_fault_reason=None, 

245 ) 

246 

247 def encode_value(self, data: BatteryPowerStateData) -> bytearray: 

248 """Encode BatteryPowerStateData back to bytes. 

249 

250 Args: 

251 data: BatteryPowerStateData instance to encode 

252 

253 Returns: 

254 Encoded bytes representing the battery power state 

255 

256 Raises: 

257 ValueError: If data contains invalid values 

258 

259 """ 

260 # For simplicity, we'll encode to the basic single-byte format 

261 # Future enhancement could support the extended formats 

262 

263 # Map battery_present to bits 0-1 

264 battery_present_bits = data.battery_present.value 

265 

266 # Map charge state to bits 4-5 

267 charge_state_mapping = { 

268 BatteryChargeState.UNKNOWN: 0, 

269 BatteryChargeState.CHARGING: 1, 

270 BatteryChargeState.DISCHARGING: 2, 

271 BatteryChargeState.NOT_CHARGING: 3, 

272 } 

273 charge_state_bits = charge_state_mapping.get(data.battery_charge_state, 0) 

274 

275 # Map charge level to bits 6-7 (need to adjust mapping for basic format) 

276 # The basic format uses different ordering than the enum values 

277 charge_level_mapping = { 

278 BatteryChargeLevel.UNKNOWN: 0, 

279 BatteryChargeLevel.CRITICALLY_LOW: 1, 

280 BatteryChargeLevel.LOW: 2, 

281 BatteryChargeLevel.GOOD: 3, 

282 } 

283 charge_level_bits = charge_level_mapping.get(data.battery_charge_level, 0) 

284 

285 # Encode single byte 

286 encoded_byte = BitFieldUtils.merge_bit_fields( 

287 ( 

288 battery_present_bits, 

289 BatteryPowerStateBits.BATTERY_PRESENT_START_BIT, 

290 BatteryPowerStateBits.BATTERY_PRESENT_NUM_BITS, 

291 ), 

292 ( 

293 1 if data.wired_external_power_connected else 0, 

294 BatteryPowerStateBits.WIRED_POWER_CONNECTED_BIT, 

295 1, 

296 ), 

297 ( 

298 1 if data.wireless_external_power_connected else 0, 

299 BatteryPowerStateBits.WIRELESS_POWER_CONNECTED_BIT, 

300 1, 

301 ), 

302 ( 

303 charge_state_bits, 

304 BatteryPowerStateBits.CHARGE_STATE_START_BIT, 

305 BatteryPowerStateBits.CHARGE_STATE_NUM_BITS, 

306 ), 

307 ( 

308 charge_level_bits, 

309 BatteryPowerStateBits.CHARGE_LEVEL_START_BIT, 

310 BatteryPowerStateBits.CHARGE_LEVEL_NUM_BITS, 

311 ), 

312 ) 

313 

314 return bytearray([encoded_byte]) 

315 

316 def _parse_power_state_16(self, power_state_raw: int) -> BatteryPowerState: 

317 """Parse the 16-bit Power State bitfield into its components. 

318 

319 Returns a BatteryPowerState dataclass with the parsed 

320 components. 

321 """ 

322 # battery present (bit 0): 0 = No/Not present, 1 = Present 

323 battery_present = ( 

324 BatteryPresentState.PRESENT 

325 if BitFieldUtils.test_bit(power_state_raw, BatteryPowerStateBits.BATTERY_PRESENT_EXT_BIT) 

326 else BatteryPresentState.NOT_PRESENT 

327 ) 

328 

329 # Wired external power: bits 1-2 (2-bit value: 0=No,1=Yes,2=Unknown,3=RFU) 

330 wired_external_power_connected = ( 

331 BitFieldUtils.extract_bit_field( 

332 power_state_raw, 

333 BatteryPowerStateBits.WIRED_POWER_EXT_START_BIT, 

334 BatteryPowerStateBits.WIRED_POWER_EXT_NUM_BITS, 

335 ) 

336 == 1 

337 ) 

338 

339 # Wireless external power: bits 3-4 

340 wireless_external_power_connected = ( 

341 BitFieldUtils.extract_bit_field( 

342 power_state_raw, 

343 BatteryPowerStateBits.WIRELESS_POWER_EXT_START_BIT, 

344 BatteryPowerStateBits.WIRELESS_POWER_EXT_NUM_BITS, 

345 ) 

346 == 1 

347 ) 

348 

349 # Charge state: bits 5-6 

350 charge_state_raw = BitFieldUtils.extract_bit_field( 

351 power_state_raw, 

352 BatteryPowerStateBits.CHARGE_STATE_EXT_START_BIT, 

353 BatteryPowerStateBits.CHARGE_STATE_EXT_NUM_BITS, 

354 ) 

355 battery_charge_state = BatteryChargeState.from_byte(charge_state_raw) 

356 

357 # Charge level: bits 7-8 

358 charge_level_raw = BitFieldUtils.extract_bit_field( 

359 power_state_raw, 

360 BatteryPowerStateBits.CHARGE_LEVEL_EXT_START_BIT, 

361 BatteryPowerStateBits.CHARGE_LEVEL_EXT_NUM_BITS, 

362 ) 

363 battery_charge_level = BatteryChargeLevel.from_byte(charge_level_raw) 

364 

365 # charging type: bits 9-11 

366 charging_type_raw = BitFieldUtils.extract_bit_field( 

367 power_state_raw, 

368 BatteryPowerStateBits.CHARGING_TYPE_START_BIT, 

369 BatteryPowerStateBits.CHARGING_TYPE_NUM_BITS, 

370 ) 

371 battery_charging_type = BatteryChargingType.from_byte(charging_type_raw) 

372 

373 # charging faults are a 3-bit flag field at bits 12..14 

374 fault_bits = BitFieldUtils.extract_bit_field( 

375 power_state_raw, 

376 BatteryPowerStateBits.FAULT_BITS_START_BIT, 

377 BatteryPowerStateBits.FAULT_BITS_NUM_BITS, 

378 ) 

379 fault_reasons: list[BatteryFaultReason] = [] 

380 if BitFieldUtils.test_bit(fault_bits, BatteryPowerStateBits.BATTERY_FAULT_BIT): 

381 fault_reasons.append(BatteryFaultReason.BATTERY_FAULT) 

382 if BitFieldUtils.test_bit(fault_bits, BatteryPowerStateBits.EXTERNAL_POWER_FAULT_BIT): 

383 fault_reasons.append(BatteryFaultReason.EXTERNAL_POWER_FAULT) 

384 if BitFieldUtils.test_bit(fault_bits, BatteryPowerStateBits.OTHER_FAULT_BIT): 

385 fault_reasons.append(BatteryFaultReason.OTHER_FAULT) 

386 

387 charging_fault_reason = fault_reasons[0] if len(fault_reasons) == 1 else (tuple(fault_reasons) or None) 

388 

389 return BatteryPowerState( 

390 battery_present=battery_present, 

391 wired_external_power_connected=wired_external_power_connected, 

392 wireless_external_power_connected=wireless_external_power_connected, 

393 battery_charge_state=battery_charge_state, 

394 battery_charge_level=battery_charge_level, 

395 battery_charging_type=battery_charging_type, 

396 charging_fault_reason=charging_fault_reason, 

397 ) 

398 

399 def _parse_basic_state(self, state_raw: int) -> BatteryPowerState: 

400 """Parse the single-byte basic state representation.""" 

401 battery_present_raw = BitFieldUtils.extract_bit_field( 

402 state_raw, 

403 BatteryPowerStateBits.BATTERY_PRESENT_START_BIT, 

404 BatteryPowerStateBits.BATTERY_PRESENT_NUM_BITS, 

405 ) 

406 battery_present = BatteryPresentState.from_byte(battery_present_raw) 

407 

408 wired_external_power_connected = bool( 

409 BitFieldUtils.test_bit(state_raw, BatteryPowerStateBits.WIRED_POWER_CONNECTED_BIT) 

410 ) 

411 wireless_external_power_connected = bool( 

412 BitFieldUtils.test_bit(state_raw, BatteryPowerStateBits.WIRELESS_POWER_CONNECTED_BIT) 

413 ) 

414 

415 charge_state_raw = BitFieldUtils.extract_bit_field( 

416 state_raw, 

417 BatteryPowerStateBits.CHARGE_STATE_START_BIT, 

418 BatteryPowerStateBits.CHARGE_STATE_NUM_BITS, 

419 ) 

420 battery_charge_state = BatteryChargeState.from_byte(charge_state_raw) 

421 

422 charge_level_raw = BitFieldUtils.extract_bit_field( 

423 state_raw, 

424 BatteryPowerStateBits.CHARGE_LEVEL_START_BIT, 

425 BatteryPowerStateBits.CHARGE_LEVEL_NUM_BITS, 

426 ) 

427 # For basic format, the charge level mapping is different 

428 if charge_level_raw == 0: 

429 battery_charge_level = BatteryChargeLevel.UNKNOWN 

430 elif charge_level_raw == 1: 

431 battery_charge_level = BatteryChargeLevel.CRITICALLY_LOW 

432 elif charge_level_raw == 2: 

433 battery_charge_level = BatteryChargeLevel.LOW 

434 elif charge_level_raw == 3: 

435 battery_charge_level = BatteryChargeLevel.GOOD 

436 else: 

437 battery_charge_level = BatteryChargeLevel.UNKNOWN 

438 

439 return BatteryPowerState( 

440 battery_present=battery_present, 

441 wired_external_power_connected=wired_external_power_connected, 

442 wireless_external_power_connected=wireless_external_power_connected, 

443 battery_charge_state=battery_charge_state, 

444 battery_charge_level=battery_charge_level, 

445 )