Coverage for src/bluetooth_sig/gatt/characteristics/local_time_information.py: 93%
68 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"""Local Time Information characteristic implementation."""
3from __future__ import annotations
5from enum import IntEnum
7import msgspec
9from ..constants import SINT8_MIN
10from ..context import CharacteristicContext
11from .base import BaseCharacteristic
14class DSTOffset(IntEnum):
15 """DST offset values as an IntEnum to avoid magic numbers.
17 Values correspond to the Bluetooth SIG encoded DST offset values.
18 """
20 STANDARD = 0
21 HALF_HOUR = 2
22 DAYLIGHT = 4
23 DOUBLE_DAYLIGHT = 8
24 UNKNOWN = 255
26 @property
27 def description(self) -> str:
28 """Human-readable description for this DST offset value."""
29 return {
30 DSTOffset.STANDARD: "Standard Time",
31 DSTOffset.HALF_HOUR: "Half an hour Daylight Time",
32 DSTOffset.DAYLIGHT: "Daylight Time",
33 DSTOffset.DOUBLE_DAYLIGHT: "Double Daylight Time",
34 DSTOffset.UNKNOWN: "DST offset unknown",
35 }[self]
37 @property
38 def offset_hours(self) -> float | None:
39 """Return the DST offset in hours (e.g. 0.5 for half hour), or None if unknown."""
40 return {
41 DSTOffset.STANDARD: 0.0,
42 DSTOffset.HALF_HOUR: 0.5,
43 DSTOffset.DAYLIGHT: 1.0,
44 DSTOffset.DOUBLE_DAYLIGHT: 2.0,
45 DSTOffset.UNKNOWN: None,
46 }[self]
49class TimezoneInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
50 """Timezone information part of local time data."""
52 description: str
53 offset_hours: float | None
54 raw_value: int
57class DSTOffsetInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
58 """DST offset information part of local time data."""
60 description: str
61 offset_hours: float | None
62 raw_value: int
65class LocalTimeInformationData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
66 """Parsed data from Local Time Information characteristic."""
68 timezone: TimezoneInfo
69 dst_offset: DSTOffsetInfo
70 total_offset_hours: float | None = None
73class LocalTimeInformationCharacteristic(BaseCharacteristic):
74 """Local Time Information characteristic (0x2A0F).
76 org.bluetooth.characteristic.local_time_information
78 Local time information characteristic.
80 Represents the relation (offset) between local time and UTC.
81 Contains time zone and Daylight Savings Time (DST) offset
82 information.
83 """
85 def decode_value( # pylint: disable=too-many-locals
86 self,
87 data: bytearray,
88 ctx: CharacteristicContext | None = None,
89 ) -> LocalTimeInformationData:
90 """Parse local time information data (2 bytes: time zone + DST offset).
92 Args:
93 data: Raw bytearray from BLE characteristic.
94 ctx: Optional CharacteristicContext providing surrounding context (may be None).
96 """
97 if len(data) < 2:
98 raise ValueError("Local time information data must be at least 2 bytes")
100 # Parse time zone (sint8)
101 timezone_raw = int.from_bytes(data[:1], byteorder="little", signed=True)
103 # Parse DST offset (uint8)
104 dst_offset_raw = data[1]
106 # Process time zone
107 if timezone_raw == SINT8_MIN:
108 timezone_desc = "Unknown"
109 timezone_hours = None
110 elif -48 <= timezone_raw <= 56:
111 # pylint: disable=duplicate-code
112 # NOTE: UTC offset formatting is shared with TimeZoneCharacteristic.
113 # Both use identical 15-minute increment conversion per Bluetooth SIG time spec.
114 # Consolidation not practical as they're independent characteristics with different data structures.
115 total_minutes = timezone_raw * 15
116 hours = total_minutes // 60
117 minutes = abs(total_minutes % 60)
118 sign = "+" if total_minutes >= 0 else "-"
119 hours_abs = abs(hours)
121 if minutes == 0:
122 timezone_desc = f"UTC{sign}{hours_abs:02d}:00"
123 else:
124 timezone_desc = f"UTC{sign}{hours_abs:02d}:{minutes:02d}"
125 timezone_hours = total_minutes / 60
126 else:
127 timezone_desc = f"Reserved (value: {timezone_raw})"
128 timezone_hours = None
130 # Process DST offset
131 try:
132 dst_enum = DSTOffset(dst_offset_raw)
133 dst_desc = dst_enum.description
134 dst_hours: float | None = dst_enum.offset_hours
135 except ValueError:
136 dst_desc = f"Reserved (value: {dst_offset_raw})"
137 dst_hours = None
139 # Create timezone info
140 timezone_info = TimezoneInfo(
141 description=timezone_desc,
142 offset_hours=timezone_hours,
143 raw_value=timezone_raw,
144 )
146 # Create DST offset info
147 dst_offset_info = DSTOffsetInfo(
148 description=dst_desc,
149 offset_hours=dst_hours,
150 raw_value=dst_offset_raw,
151 )
153 # Calculate total offset if both are known
154 total_offset = None
155 if timezone_hours is not None and dst_hours is not None:
156 total_offset = timezone_hours + dst_hours
158 return LocalTimeInformationData(
159 timezone=timezone_info,
160 dst_offset=dst_offset_info,
161 total_offset_hours=total_offset,
162 )
164 def encode_value(self, data: LocalTimeInformationData) -> bytearray:
165 """Encode LocalTimeInformationData back to bytes.
167 Args:
168 data: LocalTimeInformationData instance to encode
170 Returns:
171 Encoded bytes representing the local time information
173 """
174 # Encode timezone (use raw value directly)
175 timezone_byte = data.timezone.raw_value.to_bytes(1, byteorder="little", signed=True)
177 # Encode DST offset (use raw value directly)
178 dst_offset_byte = data.dst_offset.raw_value.to_bytes(1, byteorder="little", signed=False)
180 return bytearray(timezone_byte + dst_offset_byte)