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