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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Battery Information characteristic implementation.
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.
7All flag bits use normal logic (1 = present, 0 = absent).
9References:
10 Bluetooth SIG Battery Service 1.1
11 org.bluetooth.characteristic.battery_information (GSS YAML)
12"""
14from __future__ import annotations
16from enum import IntEnum, IntFlag
18import msgspec
20from ..constants import UINT8_MAX, UINT24_MAX
21from ..context import CharacteristicContext
22from .base import BaseCharacteristic
23from .utils import DataParser, IEEE11073Parser
26class BatteryInformationFlags(IntFlag):
27 """Battery Information flags as per Bluetooth SIG specification."""
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
39class BatteryFeatures(IntFlag):
40 """Battery Features bitfield as per Bluetooth SIG specification."""
42 REPLACEABLE = 0x01
43 RECHARGEABLE = 0x02
46class BatteryChemistry(IntEnum):
47 """Battery Chemistry enumeration as per Bluetooth SIG specification."""
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
66class BatteryInformation(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
67 """Parsed data from Battery Information characteristic.
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.
89 """
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
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}")
112class BatteryInformationCharacteristic(
113 BaseCharacteristic[BatteryInformation],
114):
115 """Battery Information characteristic (0x2BEC).
117 Reports physical battery characteristics including features, dates,
118 capacity, chemistry, voltage, and aggregation group.
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
131 The mandatory Battery Features byte is always present after the flags.
133 """
135 expected_type = BatteryInformation
136 min_length: int = 3 # 2 bytes flags + 1 byte mandatory features
137 allow_variable_length: bool = True
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.
148 Args:
149 data: Raw bytearray from BLE characteristic.
150 ctx: Optional context (unused).
151 validate: Whether to validate ranges.
153 Returns:
154 BatteryInformation with all present fields populated.
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
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
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
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
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
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
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
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
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
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 )
226 def _encode_value(self, data: BatteryInformation) -> bytearray:
227 """Encode BatteryInformation back to BLE bytes.
229 Args:
230 data: BatteryInformation instance.
232 Returns:
233 Encoded bytearray matching the BLE wire format.
235 """
236 flags = BatteryInformationFlags(0)
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
255 result = DataParser.encode_int16(int(flags), signed=False)
256 result.extend(DataParser.encode_int8(int(data.battery_features), signed=False))
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))
275 return result