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

1"""Local Time Information characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum 

6 

7import msgspec 

8 

9from ..constants import SINT8_MIN 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .utils import DataParser 

13 

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) 

17 

18 

19class DSTOffset(IntEnum): 

20 """DST offset values as an IntEnum to avoid magic numbers. 

21 

22 Values correspond to the Bluetooth SIG encoded DST offset values. 

23 """ 

24 

25 STANDARD = 0 

26 HALF_HOUR = 2 

27 DAYLIGHT = 4 

28 DOUBLE_DAYLIGHT = 8 

29 UNKNOWN = 255 

30 

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] 

41 

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] 

52 

53 

54class TimezoneInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

55 """Timezone information part of local time data.""" 

56 

57 description: str 

58 offset_hours: float | None 

59 raw_value: int 

60 

61 

62class DSTOffsetInfo(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

63 """DST offset information part of local time data.""" 

64 

65 description: str 

66 offset_hours: float | None 

67 raw_value: int 

68 

69 

70class LocalTimeInformationData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

71 """Parsed data from Local Time Information characteristic.""" 

72 

73 timezone: TimezoneInfo 

74 dst_offset: DSTOffsetInfo 

75 total_offset_hours: float | None = None 

76 

77 

78class LocalTimeInformationCharacteristic(BaseCharacteristic[LocalTimeInformationData]): 

79 """Local Time Information characteristic (0x2A0F). 

80 

81 org.bluetooth.characteristic.local_time_information 

82 

83 Local time information characteristic. 

84 

85 Represents the relation (offset) between local time and UTC. 

86 Contains time zone and Daylight Savings Time (DST) offset 

87 information. 

88 """ 

89 

90 expected_length: int = 2 # Timezone(1) + DST Offset(1) 

91 min_length: int = 2 

92 

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). 

101 

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) 

106 

107 """ 

108 # Parse time zone (sint8) 

109 timezone_raw = DataParser.parse_int8(data, 0, signed=True) 

110 

111 # Parse DST offset (uint8) 

112 dst_offset_raw = data[1] 

113 

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) 

128 

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 

137 

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 

146 

147 # Create timezone info 

148 timezone_info = TimezoneInfo( 

149 description=timezone_desc, 

150 offset_hours=timezone_hours, 

151 raw_value=timezone_raw, 

152 ) 

153 

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 ) 

160 

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 

165 

166 return LocalTimeInformationData( 

167 timezone=timezone_info, 

168 dst_offset=dst_offset_info, 

169 total_offset_hours=total_offset, 

170 ) 

171 

172 def _encode_value(self, data: LocalTimeInformationData) -> bytearray: 

173 """Encode LocalTimeInformationData back to bytes. 

174 

175 Args: 

176 data: LocalTimeInformationData instance to encode 

177 

178 Returns: 

179 Encoded bytes representing the local time information 

180 

181 """ 

182 # Encode timezone (use raw value directly) 

183 timezone_byte = DataParser.encode_int8(data.timezone.raw_value, signed=True) 

184 

185 # Encode DST offset (use raw value directly) 

186 dst_offset_byte = DataParser.encode_int8(data.dst_offset.raw_value, signed=False) 

187 

188 return bytearray(timezone_byte + dst_offset_byte)