Coverage for src / bluetooth_sig / gatt / characteristics / local_time_information.py: 90%
70 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +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
12from .utils import DataParser
15class DSTOffset(IntEnum):
16 """DST offset values as an IntEnum to avoid magic numbers.
18 Values correspond to the Bluetooth SIG encoded DST offset values.
19 """
21 STANDARD = 0
22 HALF_HOUR = 2
23 DAYLIGHT = 4
24 DOUBLE_DAYLIGHT = 8
25 UNKNOWN = 255
27 @property
28 def description(self) -> str:
29 """Human-readable description for this DST offset value."""
30 return {
31 DSTOffset.STANDARD: "Standard Time",
32 DSTOffset.HALF_HOUR: "Half an hour Daylight Time",
33 DSTOffset.DAYLIGHT: "Daylight Time",
34 DSTOffset.DOUBLE_DAYLIGHT: "Double Daylight Time",
35 DSTOffset.UNKNOWN: "DST offset unknown",
36 }[self]
38 @property
39 def offset_hours(self) -> float | None:
40 """Return the DST offset in hours (e.g. 0.5 for half hour), or None if unknown."""
41 return {
42 DSTOffset.STANDARD: 0.0,
43 DSTOffset.HALF_HOUR: 0.5,
44 DSTOffset.DAYLIGHT: 1.0,
45 DSTOffset.DOUBLE_DAYLIGHT: 2.0,
46 DSTOffset.UNKNOWN: None,
47 }[self]
50class TimezoneInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
51 """Timezone information part of local time data."""
53 description: str
54 offset_hours: float | None
55 raw_value: int
58class DSTOffsetInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
59 """DST offset information part of local time data."""
61 description: str
62 offset_hours: float | None
63 raw_value: int
66class LocalTimeInformationData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
67 """Parsed data from Local Time Information characteristic."""
69 timezone: TimezoneInfo
70 dst_offset: DSTOffsetInfo
71 total_offset_hours: float | None = None
74class LocalTimeInformationCharacteristic(BaseCharacteristic[LocalTimeInformationData]):
75 """Local Time Information characteristic (0x2A0F).
77 org.bluetooth.characteristic.local_time_information
79 Local time information characteristic.
81 Represents the relation (offset) between local time and UTC.
82 Contains time zone and Daylight Savings Time (DST) offset
83 information.
84 """
86 expected_length: int = 2 # Timezone(1) + DST Offset(1)
88 def _decode_value( # pylint: disable=too-many-locals
89 self,
90 data: bytearray,
91 ctx: CharacteristicContext | None = None,
92 ) -> LocalTimeInformationData:
93 """Parse local time information data (2 bytes: time zone + DST offset).
95 Args:
96 data: Raw bytearray from BLE characteristic.
97 ctx: Optional CharacteristicContext providing surrounding context (may be None).
99 """
100 if len(data) < 2:
101 raise ValueError("Local time information data must be at least 2 bytes")
103 # Parse time zone (sint8)
104 timezone_raw = DataParser.parse_int8(data, 0, signed=True)
106 # Parse DST offset (uint8)
107 dst_offset_raw = data[1]
109 # Process time zone
110 if timezone_raw == SINT8_MIN:
111 timezone_desc = "Unknown"
112 timezone_hours = None
113 elif -48 <= timezone_raw <= 56:
114 # pylint: disable=duplicate-code
115 # NOTE: UTC offset formatting is shared with TimeZoneCharacteristic.
116 # Both use identical 15-minute increment conversion per Bluetooth SIG time spec.
117 # Consolidation not practical as they're independent characteristics with different data structures.
118 total_minutes = timezone_raw * 15
119 hours = total_minutes // 60
120 minutes = abs(total_minutes % 60)
121 sign = "+" if total_minutes >= 0 else "-"
122 hours_abs = abs(hours)
124 if minutes == 0:
125 timezone_desc = f"UTC{sign}{hours_abs:02d}:00"
126 else:
127 timezone_desc = f"UTC{sign}{hours_abs:02d}:{minutes:02d}"
128 timezone_hours = total_minutes / 60
129 else:
130 timezone_desc = f"Reserved (value: {timezone_raw})"
131 timezone_hours = None
133 # Process DST offset
134 try:
135 dst_enum = DSTOffset(dst_offset_raw)
136 dst_desc = dst_enum.description
137 dst_hours: float | None = dst_enum.offset_hours
138 except ValueError:
139 dst_desc = f"Reserved (value: {dst_offset_raw})"
140 dst_hours = None
142 # Create timezone info
143 timezone_info = TimezoneInfo(
144 description=timezone_desc,
145 offset_hours=timezone_hours,
146 raw_value=timezone_raw,
147 )
149 # Create DST offset info
150 dst_offset_info = DSTOffsetInfo(
151 description=dst_desc,
152 offset_hours=dst_hours,
153 raw_value=dst_offset_raw,
154 )
156 # Calculate total offset if both are known
157 total_offset = None
158 if timezone_hours is not None and dst_hours is not None:
159 total_offset = timezone_hours + dst_hours
161 return LocalTimeInformationData(
162 timezone=timezone_info,
163 dst_offset=dst_offset_info,
164 total_offset_hours=total_offset,
165 )
167 def _encode_value(self, data: LocalTimeInformationData) -> bytearray:
168 """Encode LocalTimeInformationData back to bytes.
170 Args:
171 data: LocalTimeInformationData instance to encode
173 Returns:
174 Encoded bytes representing the local time information
176 """
177 # Encode timezone (use raw value directly)
178 timezone_byte = DataParser.encode_int8(data.timezone.raw_value, signed=True)
180 # Encode DST offset (use raw value directly)
181 dst_offset_byte = DataParser.encode_int8(data.dst_offset.raw_value, signed=False)
183 return bytearray(timezone_byte + dst_offset_byte)