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

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 

12 

13 

14class DSTOffset(IntEnum): 

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

16 

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

18 """ 

19 

20 STANDARD = 0 

21 HALF_HOUR = 2 

22 DAYLIGHT = 4 

23 DOUBLE_DAYLIGHT = 8 

24 UNKNOWN = 255 

25 

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] 

36 

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] 

47 

48 

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

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

51 

52 description: str 

53 offset_hours: float | None 

54 raw_value: int 

55 

56 

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

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

59 

60 description: str 

61 offset_hours: float | None 

62 raw_value: int 

63 

64 

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

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

67 

68 timezone: TimezoneInfo 

69 dst_offset: DSTOffsetInfo 

70 total_offset_hours: float | None = None 

71 

72 

73class LocalTimeInformationCharacteristic(BaseCharacteristic): 

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

75 

76 org.bluetooth.characteristic.local_time_information 

77 

78 Local time information characteristic. 

79 

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

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

82 information. 

83 """ 

84 

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

91 

92 Args: 

93 data: Raw bytearray from BLE characteristic. 

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

95 

96 """ 

97 if len(data) < 2: 

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

99 

100 # Parse time zone (sint8) 

101 timezone_raw = int.from_bytes(data[:1], byteorder="little", signed=True) 

102 

103 # Parse DST offset (uint8) 

104 dst_offset_raw = data[1] 

105 

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) 

120 

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 

129 

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 

138 

139 # Create timezone info 

140 timezone_info = TimezoneInfo( 

141 description=timezone_desc, 

142 offset_hours=timezone_hours, 

143 raw_value=timezone_raw, 

144 ) 

145 

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 ) 

152 

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 

157 

158 return LocalTimeInformationData( 

159 timezone=timezone_info, 

160 dst_offset=dst_offset_info, 

161 total_offset_hours=total_offset, 

162 ) 

163 

164 def encode_value(self, data: LocalTimeInformationData) -> bytearray: 

165 """Encode LocalTimeInformationData back to bytes. 

166 

167 Args: 

168 data: LocalTimeInformationData instance to encode 

169 

170 Returns: 

171 Encoded bytes representing the local time information 

172 

173 """ 

174 # Encode timezone (use raw value directly) 

175 timezone_byte = data.timezone.raw_value.to_bytes(1, byteorder="little", signed=True) 

176 

177 # Encode DST offset (use raw value directly) 

178 dst_offset_byte = data.dst_offset.raw_value.to_bytes(1, byteorder="little", signed=False) 

179 

180 return bytearray(timezone_byte + dst_offset_byte)