Coverage for src / bluetooth_sig / gatt / characteristics / templates / time_duration.py: 94%

68 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Time-duration template returning ``timedelta`` for BLE time characteristics. 

2 

3Wraps a numeric extractor and converts raw integer counts (seconds, 

4milliseconds, hours, …) into :class:`datetime.timedelta` instances so 

5that callers receive a proper Python time type instead of a plain ``int``. 

6""" 

7 

8from __future__ import annotations 

9 

10import math 

11from datetime import timedelta 

12 

13from ...context import CharacteristicContext 

14from ...exceptions import InsufficientDataError 

15from ..utils.extractors import ( 

16 UINT8, 

17 UINT16, 

18 UINT24, 

19 UINT32, 

20 RawExtractor, 

21) 

22from .base import CodingTemplate 

23 

24 

25class TimeDurationTemplate(CodingTemplate[timedelta]): 

26 r"""Template for time-duration characteristics that return ``timedelta``. 

27 

28 Encodes/decodes a raw integer count in a given time unit (seconds, 

29 milliseconds, hours, ...) to/from a ``timedelta``. 

30 

31 Pipeline Integration: 

32 bytes -> [extractor] -> raw_int -> x scale -> timedelta(seconds=...) 

33 

34 Examples: 

35 >>> template = TimeDurationTemplate.seconds_uint16() 

36 >>> template.decode_value(bytearray([0x2A, 0x00])) 

37 datetime.timedelta(seconds=42) 

38 >>> 

39 >>> template.encode_value(timedelta(seconds=42)) 

40 bytearray(b'*\\x00') 

41 """ 

42 

43 def __init__( 

44 self, 

45 extractor: RawExtractor, 

46 *, 

47 seconds_per_unit: float = 1.0, 

48 ) -> None: 

49 """Initialise with extractor and time-unit conversion factor. 

50 

51 Args: 

52 extractor: Raw extractor defining byte size and signedness. 

53 seconds_per_unit: How many seconds one raw count represents. 

54 E.g. ``1.0`` for seconds, ``0.001`` for ms, 

55 ``3600.0`` for hours, ``360.0`` for deci-hours. 

56 

57 """ 

58 self._extractor = extractor 

59 self._seconds_per_unit = seconds_per_unit 

60 

61 @property 

62 def data_size(self) -> int: 

63 """Return byte size required for encoding.""" 

64 return self._extractor.byte_size 

65 

66 @property 

67 def extractor(self) -> RawExtractor: 

68 """Return extractor for pipeline access.""" 

69 return self._extractor 

70 

71 def decode_value( 

72 self, 

73 data: bytearray, 

74 offset: int = 0, 

75 ctx: CharacteristicContext | None = None, 

76 *, 

77 validate: bool = True, 

78 ) -> timedelta: 

79 """Decode bytes to ``timedelta``. 

80 

81 Args: 

82 data: Raw bytes from BLE characteristic. 

83 offset: Starting offset in data buffer. 

84 ctx: Optional context for parsing. 

85 validate: Whether to validate data length (default True). 

86 

87 Returns: 

88 ``timedelta`` representing the decoded duration. 

89 

90 Raises: 

91 InsufficientDataError: If data too short for required byte size. 

92 

93 """ 

94 if validate and len(data) < offset + self.data_size: 

95 raise InsufficientDataError("TimeDuration", data[offset:], self.data_size) 

96 

97 raw = self._extractor.extract(data, offset) 

98 return timedelta(seconds=raw * self._seconds_per_unit) 

99 

100 def encode_value(self, value: timedelta | int | float, *, validate: bool = True) -> bytearray: 

101 """Encode ``timedelta`` (or numeric seconds) to bytes. 

102 

103 Args: 

104 value: ``timedelta``, or a numeric value treated as the raw count. 

105 validate: Whether to validate (default True). 

106 

107 Returns: 

108 Encoded bytes. 

109 

110 """ 

111 raw = round(value.total_seconds() / self._seconds_per_unit) if isinstance(value, timedelta) else int(value) 

112 

113 return self._extractor.pack(raw) 

114 

115 # ----------------------------------------------------------------- 

116 # Factory methods 

117 # ----------------------------------------------------------------- 

118 

119 @classmethod 

120 def seconds_uint8(cls) -> TimeDurationTemplate: 

121 """1-byte unsigned, 1-second resolution.""" 

122 return cls(UINT8, seconds_per_unit=1.0) 

123 

124 @classmethod 

125 def seconds_uint16(cls) -> TimeDurationTemplate: 

126 """2-byte unsigned, 1-second resolution.""" 

127 return cls(UINT16, seconds_per_unit=1.0) 

128 

129 @classmethod 

130 def seconds_uint24(cls) -> TimeDurationTemplate: 

131 """3-byte unsigned, 1-second resolution.""" 

132 return cls(UINT24, seconds_per_unit=1.0) 

133 

134 @classmethod 

135 def seconds_uint32(cls) -> TimeDurationTemplate: 

136 """4-byte unsigned, 1-second resolution.""" 

137 return cls(UINT32, seconds_per_unit=1.0) 

138 

139 @classmethod 

140 def milliseconds_uint24(cls) -> TimeDurationTemplate: 

141 """3-byte unsigned, 1-millisecond resolution.""" 

142 return cls(UINT24, seconds_per_unit=0.001) 

143 

144 @classmethod 

145 def hours_uint24(cls) -> TimeDurationTemplate: 

146 """3-byte unsigned, 1-hour resolution.""" 

147 return cls(UINT24, seconds_per_unit=3600.0) 

148 

149 @classmethod 

150 def decihours_uint8(cls) -> TimeDurationTemplate: 

151 """1-byte unsigned, 0.1-hour (6-minute) resolution.""" 

152 return cls(UINT8, seconds_per_unit=360.0) 

153 

154 

155class TimeExponentialTemplate(CodingTemplate[timedelta]): 

156 """Template for exponentially-encoded time (Time Exponential 8). 

157 

158 Encoding: ``value = 1.1^(N - 64)`` seconds. 

159 Special values: ``0x00`` = 0 s, ``0xFE`` = device lifetime, ``0xFF`` = unknown. 

160 """ 

161 

162 @property 

163 def data_size(self) -> int: 

164 """Size: 1 byte.""" 

165 return 1 

166 

167 @property 

168 def extractor(self) -> RawExtractor: 

169 """Return uint8 extractor for pipeline access.""" 

170 return UINT8 

171 

172 def decode_value( 

173 self, 

174 data: bytearray, 

175 offset: int = 0, 

176 ctx: CharacteristicContext | None = None, 

177 *, 

178 validate: bool = True, 

179 ) -> timedelta: 

180 """Decode exponentially-encoded time to ``timedelta``. 

181 

182 Args: 

183 data: Raw bytes from BLE characteristic. 

184 offset: Starting offset in data buffer. 

185 ctx: Optional context for parsing. 

186 validate: Whether to validate data length (default True). 

187 

188 Returns: 

189 ``timedelta`` representing the decoded duration. 

190 

191 Raises: 

192 InsufficientDataError: If data too short. 

193 

194 """ 

195 if validate and len(data) < offset + self.data_size: 

196 raise InsufficientDataError("TimeExponential8", data[offset:], 1) 

197 

198 raw = UINT8.extract(data, offset) 

199 if raw == 0: 

200 return timedelta(seconds=0) 

201 seconds = 1.1 ** (raw - 64) 

202 return timedelta(seconds=seconds) 

203 

204 def encode_value(self, value: timedelta | float, *, validate: bool = True) -> bytearray: 

205 """Encode a time duration using exponential encoding. 

206 

207 Args: 

208 value: ``timedelta`` or numeric seconds. 

209 validate: Whether to validate (default True). 

210 

211 Returns: 

212 Encoded byte. 

213 

214 """ 

215 seconds = value.total_seconds() if isinstance(value, timedelta) else float(value) 

216 

217 if seconds <= 0.0: 

218 return UINT8.pack(0) 

219 

220 n = round(math.log(seconds) / math.log(1.1) + 64) 

221 n = max(1, min(n, 0xFD)) 

222 return UINT8.pack(n)