Coverage for src / bluetooth_sig / gatt / characteristics / battery_health_information.py: 96%
56 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 Information characteristic implementation.
3Implements the Battery Health Information characteristic (0x2BEB) from the
4Battery Service. An 8-bit flags field controls the presence of optional fields.
6All flag bits use normal logic (1 = present, 0 = absent).
8Bit 1 gates two fields simultaneously: Min and Max Designed Operating
9Temperature.
11References:
12 Bluetooth SIG Battery Service 1.1
13 org.bluetooth.characteristic.battery_health_information (GSS YAML)
14"""
16from __future__ import annotations
18from enum import IntFlag
20import msgspec
22from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX
23from ..context import CharacteristicContext
24from .base import BaseCharacteristic
25from .utils import DataParser
28class BatteryHealthInformationFlags(IntFlag):
29 """Battery Health Information flags as per Bluetooth SIG specification."""
31 CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT = 0x01
32 TEMPERATURE_RANGE_PRESENT = 0x02
35class BatteryHealthInformation(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
36 """Parsed data from Battery Health Information characteristic.
38 Attributes:
39 flags: Raw 8-bit flags field.
40 cycle_count_designed_lifetime: Designed number of charge cycles.
41 None if absent.
42 min_designed_operating_temperature: Min operating temperature (C).
43 127 means ">126", -128 means "<-127". None if absent.
44 max_designed_operating_temperature: Max operating temperature (C).
45 127 means ">126", -128 means "<-127". None if absent.
47 """
49 flags: BatteryHealthInformationFlags
50 cycle_count_designed_lifetime: int | None = None
51 min_designed_operating_temperature: int | None = None
52 max_designed_operating_temperature: int | None = None
54 def __post_init__(self) -> None:
55 """Validate field ranges."""
56 if self.cycle_count_designed_lifetime is not None and not 0 <= self.cycle_count_designed_lifetime <= UINT16_MAX:
57 raise ValueError(
58 f"Cycle count designed lifetime must be 0-{UINT16_MAX}, got {self.cycle_count_designed_lifetime}"
59 )
60 if (
61 self.min_designed_operating_temperature is not None
62 and not SINT8_MIN <= self.min_designed_operating_temperature <= SINT8_MAX
63 ):
64 raise ValueError(
65 f"Min temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.min_designed_operating_temperature}"
66 )
67 if (
68 self.max_designed_operating_temperature is not None
69 and not SINT8_MIN <= self.max_designed_operating_temperature <= SINT8_MAX
70 ):
71 raise ValueError(
72 f"Max temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.max_designed_operating_temperature}"
73 )
76class BatteryHealthInformationCharacteristic(
77 BaseCharacteristic[BatteryHealthInformation],
78):
79 """Battery Health Information characteristic (0x2BEB).
81 Reports designed battery health parameters including designed cycle count
82 and designed operating temperature range.
84 Flag-bit assignments (from GSS YAML):
85 Bit 0: Cycle Count Designed Lifetime Present
86 Bit 1: Min and Max Designed Operating Temperature Present
87 Bits 2-7: Reserved for Future Use
89 Note: Bit 1 gates two fields (min + max temperature) simultaneously.
91 """
93 expected_type = BatteryHealthInformation
94 min_length: int = 1 # 1 byte flags only (all fields optional)
95 allow_variable_length: bool = True
97 def _decode_value(
98 self,
99 data: bytearray,
100 ctx: CharacteristicContext | None = None,
101 *,
102 validate: bool = True,
103 ) -> BatteryHealthInformation:
104 """Parse Battery Health Information from raw BLE bytes.
106 Args:
107 data: Raw bytearray from BLE characteristic.
108 ctx: Optional context (unused).
109 validate: Whether to validate ranges.
111 Returns:
112 BatteryHealthInformation with all present fields populated.
114 """
115 flags = BatteryHealthInformationFlags(DataParser.parse_int8(data, 0, signed=False))
116 offset = 1
118 # Bit 0 -- Cycle Count Designed Lifetime (uint16)
119 cycle_count_designed_lifetime = None
120 if flags & BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT:
121 cycle_count_designed_lifetime = DataParser.parse_int16(data, offset, signed=False)
122 offset += 2
124 # Bit 1 -- Min AND Max Designed Operating Temperature (2 x sint8)
125 min_temp = None
126 max_temp = None
127 if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT:
128 min_temp = DataParser.parse_int8(data, offset, signed=True)
129 offset += 1
130 max_temp = DataParser.parse_int8(data, offset, signed=True)
131 offset += 1
133 return BatteryHealthInformation(
134 flags=flags,
135 cycle_count_designed_lifetime=cycle_count_designed_lifetime,
136 min_designed_operating_temperature=min_temp,
137 max_designed_operating_temperature=max_temp,
138 )
140 def _encode_value(self, data: BatteryHealthInformation) -> bytearray:
141 """Encode BatteryHealthInformation back to BLE bytes.
143 Args:
144 data: BatteryHealthInformation instance.
146 Returns:
147 Encoded bytearray matching the BLE wire format.
149 """
150 flags = BatteryHealthInformationFlags(0)
152 if data.cycle_count_designed_lifetime is not None:
153 flags |= BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT
154 if data.min_designed_operating_temperature is not None or data.max_designed_operating_temperature is not None:
155 flags |= BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT
157 result = DataParser.encode_int8(int(flags), signed=False)
159 if data.cycle_count_designed_lifetime is not None:
160 result.extend(DataParser.encode_int16(data.cycle_count_designed_lifetime, signed=False))
161 if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT:
162 min_temp = (
163 data.min_designed_operating_temperature if data.min_designed_operating_temperature is not None else 0
164 )
165 max_temp = (
166 data.max_designed_operating_temperature if data.max_designed_operating_temperature is not None else 0
167 )
168 result.extend(DataParser.encode_int8(min_temp, signed=True))
169 result.extend(DataParser.encode_int8(max_temp, signed=True))
171 return result