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

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 

15class DSTOffset(IntEnum): 

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

17 

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

19 """ 

20 

21 STANDARD = 0 

22 HALF_HOUR = 2 

23 DAYLIGHT = 4 

24 DOUBLE_DAYLIGHT = 8 

25 UNKNOWN = 255 

26 

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] 

37 

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] 

48 

49 

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

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

52 

53 description: str 

54 offset_hours: float | None 

55 raw_value: int 

56 

57 

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

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

60 

61 description: str 

62 offset_hours: float | None 

63 raw_value: int 

64 

65 

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

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

68 

69 timezone: TimezoneInfo 

70 dst_offset: DSTOffsetInfo 

71 total_offset_hours: float | None = None 

72 

73 

74class LocalTimeInformationCharacteristic(BaseCharacteristic[LocalTimeInformationData]): 

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

76 

77 org.bluetooth.characteristic.local_time_information 

78 

79 Local time information characteristic. 

80 

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

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

83 information. 

84 """ 

85 

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

87 

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

94 

95 Args: 

96 data: Raw bytearray from BLE characteristic. 

97 ctx: Optional CharacteristicContext providing surrounding context (may be None). 

98 

99 """ 

100 if len(data) < 2: 

101 raise ValueError("Local time information data must be at least 2 bytes") 

102 

103 # Parse time zone (sint8) 

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

105 

106 # Parse DST offset (uint8) 

107 dst_offset_raw = data[1] 

108 

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) 

123 

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 

132 

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 

141 

142 # Create timezone info 

143 timezone_info = TimezoneInfo( 

144 description=timezone_desc, 

145 offset_hours=timezone_hours, 

146 raw_value=timezone_raw, 

147 ) 

148 

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 ) 

155 

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 

160 

161 return LocalTimeInformationData( 

162 timezone=timezone_info, 

163 dst_offset=dst_offset_info, 

164 total_offset_hours=total_offset, 

165 ) 

166 

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

168 """Encode LocalTimeInformationData back to bytes. 

169 

170 Args: 

171 data: LocalTimeInformationData instance to encode 

172 

173 Returns: 

174 Encoded bytes representing the local time information 

175 

176 """ 

177 # Encode timezone (use raw value directly) 

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

179 

180 # Encode DST offset (use raw value directly) 

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

182 

183 return bytearray(timezone_byte + dst_offset_byte)