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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
1"""Battery Level Status characteristic implementation."""
3from __future__ import annotations
5from enum import IntEnum
6from typing import Any
8import msgspec
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
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."""
21 # Flags byte bit positions
22 IDENTIFIER_PRESENT_BIT = 0
23 BATTERY_LEVEL_PRESENT_BIT = 1
24 ADDITIONAL_INFO_PRESENT_BIT = 2
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
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
51 # Fault sub-bits within fault field
52 BATTERY_FAULT_BIT = 0
53 EXTERNAL_POWER_FAULT_BIT = 1
54 OTHER_FAULT_BIT = 2
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
63class BatteryPresentState(IntEnum):
64 """Battery present state enumeration."""
66 UNKNOWN = 0
67 NOT_PRESENT = 1
68 PRESENT = 2
69 RESERVED = 3
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]
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
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."""
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
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}")
102class BatteryPowerState(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
103 """Parsed battery power state components."""
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
114class BatteryPowerStateCharacteristic(BaseCharacteristic):
115 """Battery Level Status characteristic (0x2BED).
117 This characteristic encodes battery presence, external power
118 sources, charging state, charge level and optional extended charging
119 information.
120 """
122 _characteristic_name: str | None = "Battery Level Status"
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"
129 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BatteryPowerStateData:
130 """Parse the Battery Level Status value.
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.
136 Args:
137 data: Raw bytearray from BLE characteristic.
138 ctx: Optional CharacteristicContext providing surrounding context (may be None).
140 Returns:
141 BatteryPowerStateData containing parsed battery power state values.
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")
150 # Single-byte basic state available in all variants
151 state_raw = int(data[0])
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)
158 parsed = self._parse_power_state_16(power_state_raw)
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
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).
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 )
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
195 if len(data) >= 2:
196 basic = self._parse_basic_state(state_raw)
198 second = int(data[1])
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 )
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 )
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 )
247 def encode_value(self, data: BatteryPowerStateData) -> bytearray:
248 """Encode BatteryPowerStateData back to bytes.
250 Args:
251 data: BatteryPowerStateData instance to encode
253 Returns:
254 Encoded bytes representing the battery power state
256 Raises:
257 ValueError: If data contains invalid values
259 """
260 # For simplicity, we'll encode to the basic single-byte format
261 # Future enhancement could support the extended formats
263 # Map battery_present to bits 0-1
264 battery_present_bits = data.battery_present.value
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)
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)
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 )
314 return bytearray([encoded_byte])
316 def _parse_power_state_16(self, power_state_raw: int) -> BatteryPowerState:
317 """Parse the 16-bit Power State bitfield into its components.
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 )
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 )
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 )
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)
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)
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)
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)
387 charging_fault_reason = fault_reasons[0] if len(fault_reasons) == 1 else (tuple(fault_reasons) or None)
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 )
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)
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 )
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)
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
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 )