Coverage for src / bluetooth_sig / gatt / characteristics / battery_time_status.py: 95%
63 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 Time Status characteristic implementation.
3Implements the Battery Time Status characteristic (0x2BEE) from the Battery
4Service. An 8-bit flags field controls the presence of optional time fields.
6All flag bits use **normal logic** (1 = present, 0 = absent).
8The mandatory "Time until Discharged" field and both optional time fields
9use uint24 in **minutes**. Two sentinel values are defined:
10 - 0xFFFFFF: Unknown
11 - 0xFFFFFE: Greater than 0xFFFFFD
13References:
14 Bluetooth SIG Battery Service 1.1
15 org.bluetooth.characteristic.battery_time_status (GSS YAML)
16"""
18from __future__ import annotations
20from enum import IntFlag
22import msgspec
24from ..constants import UINT24_MAX
25from ..context import CharacteristicContext
26from .base import BaseCharacteristic
27from .utils import DataParser
29# Sentinel values for time fields (uint24 minutes)
30_TIME_UNKNOWN: int = 0xFFFFFF
31_TIME_OVERFLOW: int = 0xFFFFFE
34class BatteryTimeStatusFlags(IntFlag):
35 """Battery Time Status flags as per Bluetooth SIG specification."""
37 DISCHARGED_ON_STANDBY_PRESENT = 0x01
38 RECHARGED_PRESENT = 0x02
41class BatteryTimeStatus(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
42 """Parsed data from Battery Time Status characteristic.
44 Attributes:
45 flags: Raw 8-bit flags field.
46 time_until_discharged: Estimated minutes until discharged.
47 None if raw == 0xFFFFFF (Unknown).
48 time_until_discharged_on_standby: Minutes until discharged on standby.
49 None if absent or raw == 0xFFFFFF (Unknown).
50 time_until_recharged: Minutes until recharged.
51 None if absent or raw == 0xFFFFFF (Unknown).
53 """
55 flags: BatteryTimeStatusFlags
56 time_until_discharged: int | None = None
57 time_until_discharged_on_standby: int | None = None
58 time_until_recharged: int | None = None
60 def __post_init__(self) -> None:
61 """Validate field ranges."""
62 if self.time_until_discharged is not None and not 0 <= self.time_until_discharged <= UINT24_MAX:
63 raise ValueError(f"Time until discharged must be 0-{UINT24_MAX}, got {self.time_until_discharged}")
64 if (
65 self.time_until_discharged_on_standby is not None
66 and not 0 <= self.time_until_discharged_on_standby <= UINT24_MAX
67 ):
68 raise ValueError(
69 f"Time until discharged on standby must be 0-{UINT24_MAX}, got {self.time_until_discharged_on_standby}"
70 )
71 if self.time_until_recharged is not None and not 0 <= self.time_until_recharged <= UINT24_MAX:
72 raise ValueError(f"Time until recharged must be 0-{UINT24_MAX}, got {self.time_until_recharged}")
75def _decode_time_minutes(data: bytearray, offset: int) -> tuple[int | None, int]:
76 """Decode a uint24 time field in minutes with sentinel handling.
78 Args:
79 data: Raw BLE bytes.
80 offset: Current read position.
82 Returns:
83 (value_or_none, new_offset). Returns None for the 0xFFFFFF sentinel.
85 """
86 if len(data) < offset + 3:
87 return None, offset
88 raw = DataParser.parse_int24(data, offset, signed=False)
89 if raw == _TIME_UNKNOWN:
90 return None, offset + 3
91 return raw, offset + 3
94def _encode_time_minutes(value: int | None) -> bytearray:
95 """Encode a time-in-minutes field to uint24, using sentinel for None.
97 Args:
98 value: Minutes value, or None for Unknown.
100 Returns:
101 3-byte encoded value.
103 """
104 if value is None:
105 return DataParser.encode_int24(_TIME_UNKNOWN, signed=False)
106 return DataParser.encode_int24(value, signed=False)
109class BatteryTimeStatusCharacteristic(BaseCharacteristic[BatteryTimeStatus]):
110 """Battery Time Status characteristic (0x2BEE).
112 Reports estimated times for battery discharge and recharge.
114 Flag-bit assignments (from GSS YAML):
115 Bit 0: Time until Discharged on Standby present
116 Bit 1: Time until Recharged present
117 Bits 2-7: Reserved for Future Use
119 The mandatory "Time until Discharged" field is always present after flags.
121 """
123 expected_type = BatteryTimeStatus
124 min_length: int = 4 # 1 byte flags + 3 bytes mandatory time field
125 allow_variable_length: bool = True
127 def _decode_value(
128 self,
129 data: bytearray,
130 ctx: CharacteristicContext | None = None,
131 *,
132 validate: bool = True,
133 ) -> BatteryTimeStatus:
134 """Parse Battery Time Status from raw BLE bytes.
136 Args:
137 data: Raw bytearray from BLE characteristic.
138 ctx: Optional context (unused).
139 validate: Whether to validate ranges.
141 Returns:
142 BatteryTimeStatus with all present fields populated.
144 """
145 flags = BatteryTimeStatusFlags(DataParser.parse_int8(data, 0, signed=False))
146 offset = 1
148 # Mandatory: Time until Discharged (uint24, minutes)
149 time_until_discharged, offset = _decode_time_minutes(data, offset)
151 # Bit 0 -- Time until Discharged on Standby
152 time_until_discharged_on_standby = None
153 if flags & BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT:
154 time_until_discharged_on_standby, offset = _decode_time_minutes(data, offset)
156 # Bit 1 -- Time until Recharged
157 time_until_recharged = None
158 if flags & BatteryTimeStatusFlags.RECHARGED_PRESENT:
159 time_until_recharged, offset = _decode_time_minutes(data, offset)
161 return BatteryTimeStatus(
162 flags=flags,
163 time_until_discharged=time_until_discharged,
164 time_until_discharged_on_standby=time_until_discharged_on_standby,
165 time_until_recharged=time_until_recharged,
166 )
168 def _encode_value(self, data: BatteryTimeStatus) -> bytearray:
169 """Encode BatteryTimeStatus back to BLE bytes.
171 Args:
172 data: BatteryTimeStatus instance.
174 Returns:
175 Encoded bytearray matching the BLE wire format.
177 """
178 flags = BatteryTimeStatusFlags(0)
180 if data.time_until_discharged_on_standby is not None:
181 flags |= BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT
182 if data.time_until_recharged is not None:
183 flags |= BatteryTimeStatusFlags.RECHARGED_PRESENT
185 result = DataParser.encode_int8(int(flags), signed=False)
187 # Mandatory: Time until Discharged
188 result.extend(_encode_time_minutes(data.time_until_discharged))
190 if data.time_until_discharged_on_standby is not None:
191 result.extend(_encode_time_minutes(data.time_until_discharged_on_standby))
192 if data.time_until_recharged is not None:
193 result.extend(_encode_time_minutes(data.time_until_recharged))
195 return result