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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Battery Health Status characteristic implementation.
3Implements the Battery Health Status characteristic (0x2BEA) from the Battery
4Service. An 8-bit flags field controls the presence of four optional fields.
6All flag bits use normal logic (1 = present, 0 = absent).
8References:
9 Bluetooth SIG Battery Service 1.1
10 org.bluetooth.characteristic.battery_health_status (GSS YAML)
11"""
13from __future__ import annotations
15from enum import IntFlag
17import msgspec
19from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX
20from ..context import CharacteristicContext
21from .base import BaseCharacteristic
22from .utils import DataParser
24# Maximum health summary percentage
25_HEALTH_SUMMARY_MAX: int = 100
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"
32class BatteryHealthStatusFlags(IntFlag):
33 """Battery Health Status flags as per Bluetooth SIG specification."""
35 HEALTH_SUMMARY_PRESENT = 0x01
36 CYCLE_COUNT_PRESENT = 0x02
37 CURRENT_TEMPERATURE_PRESENT = 0x04
38 DEEP_DISCHARGE_COUNT_PRESENT = 0x08
41class BatteryHealthStatus(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
42 """Parsed data from Battery Health Status characteristic.
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.
53 """
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
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}")
75class BatteryHealthStatusCharacteristic(BaseCharacteristic[BatteryHealthStatus]):
76 """Battery Health Status characteristic (0x2BEA).
78 Reports battery health information including summary percentage, cycle
79 count, temperature, and deep discharge count.
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
88 """
90 expected_type = BatteryHealthStatus
91 min_length: int = 1 # 1 byte flags only (all fields optional)
92 allow_variable_length: bool = True
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.
103 Args:
104 data: Raw bytearray from BLE characteristic.
105 ctx: Optional context (unused).
106 validate: Whether to validate ranges.
108 Returns:
109 BatteryHealthStatus with all present fields populated.
111 """
112 flags = BatteryHealthStatusFlags(DataParser.parse_int8(data, 0, signed=False))
113 offset = 1
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
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
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
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
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 )
147 def _encode_value(self, data: BatteryHealthStatus) -> bytearray:
148 """Encode BatteryHealthStatus back to BLE bytes.
150 Args:
151 data: BatteryHealthStatus instance.
153 Returns:
154 Encoded bytearray matching the BLE wire format.
156 """
157 flags = BatteryHealthStatusFlags(0)
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
168 result = DataParser.encode_int8(int(flags), signed=False)
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))
179 return result