Coverage for src / bluetooth_sig / gatt / characteristics / templates / composite.py: 69%

77 statements  

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

1"""Composite multi-field templates for time and vector data. 

2 

3Covers TimeDataTemplate, VectorTemplate, and Vector2DTemplate. 

4These templates handle multi-field structures and do NOT expose extractor/translator 

5since there is no single raw value to intercept. 

6""" 

7 

8from __future__ import annotations 

9 

10from ....types.gatt_enums import AdjustReason, DayOfWeek 

11from ...context import CharacteristicContext 

12from ...exceptions import InsufficientDataError, ValueRangeError 

13from ..utils import DataParser, IEEE11073Parser 

14from .base import CodingTemplate 

15from .data_structures import TimeData, Vector2DData, VectorData 

16 

17 

18class TimeDataTemplate(CodingTemplate[TimeData]): 

19 """Template for Bluetooth SIG time data parsing (10 bytes). 

20 

21 Used for Current Time and Time with DST characteristics. 

22 Structure: Date Time (7 bytes) + Day of Week (1) + Fractions256 (1) + Adjust Reason (1) 

23 """ 

24 

25 LENGTH = 10 

26 DAY_OF_WEEK_MAX = 7 

27 FRACTIONS256_MAX = 255 

28 ADJUST_REASON_MAX = 255 

29 

30 @property 

31 def data_size(self) -> int: 

32 """Size: 10 bytes.""" 

33 return self.LENGTH 

34 

35 def decode_value( 

36 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

37 ) -> TimeData: 

38 """Parse time data from bytes.""" 

39 if validate and len(data) < offset + self.LENGTH: 

40 raise InsufficientDataError("time data", data[offset:], self.LENGTH) 

41 

42 # Parse Date Time (7 bytes) 

43 year = DataParser.parse_int16(data, offset, signed=False) 

44 month = data[offset + 2] 

45 day = data[offset + 3] 

46 

47 date_time = None if year == 0 or month == 0 or day == 0 else IEEE11073Parser.parse_timestamp(data, offset) 

48 

49 # Parse Day of Week (1 byte) 

50 day_of_week_raw = data[offset + 7] 

51 if validate and day_of_week_raw > self.DAY_OF_WEEK_MAX: 

52 raise ValueRangeError("day_of_week", day_of_week_raw, 0, self.DAY_OF_WEEK_MAX) 

53 day_of_week = DayOfWeek(day_of_week_raw) 

54 

55 # Parse Fractions256 (1 byte) 

56 fractions256 = data[offset + 8] 

57 

58 # Parse Adjust Reason (1 byte) 

59 adjust_reason = AdjustReason.from_raw(data[offset + 9]) 

60 

61 return TimeData( 

62 date_time=date_time, day_of_week=day_of_week, fractions256=fractions256, adjust_reason=adjust_reason 

63 ) 

64 

65 def encode_value(self, value: TimeData, *, validate: bool = True) -> bytearray: 

66 """Encode time data to bytes.""" 

67 result = bytearray() 

68 

69 # Encode Date Time (7 bytes) 

70 if value.date_time is None: 

71 result.extend(bytearray(IEEE11073Parser.TIMESTAMP_LENGTH)) 

72 else: 

73 result.extend(IEEE11073Parser.encode_timestamp(value.date_time)) 

74 

75 # Encode Day of Week (1 byte) 

76 day_of_week_value = int(value.day_of_week) 

77 if validate and day_of_week_value > self.DAY_OF_WEEK_MAX: 

78 raise ValueRangeError("day_of_week", day_of_week_value, 0, self.DAY_OF_WEEK_MAX) 

79 result.append(day_of_week_value) 

80 

81 # Encode Fractions256 (1 byte) 

82 if validate and value.fractions256 > self.FRACTIONS256_MAX: 

83 raise ValueRangeError("fractions256", value.fractions256, 0, self.FRACTIONS256_MAX) 

84 result.append(value.fractions256) 

85 

86 # Encode Adjust Reason (1 byte) 

87 if validate and int(value.adjust_reason) > self.ADJUST_REASON_MAX: 

88 raise ValueRangeError("adjust_reason", int(value.adjust_reason), 0, self.ADJUST_REASON_MAX) 

89 result.append(int(value.adjust_reason)) 

90 

91 return result 

92 

93 

94class VectorTemplate(CodingTemplate[VectorData]): 

95 """Template for 3D vector measurements (x, y, z float32 components).""" 

96 

97 @property 

98 def data_size(self) -> int: 

99 """Size: 12 bytes (3 x float32).""" 

100 return 12 

101 

102 def decode_value( 

103 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

104 ) -> VectorData: 

105 """Parse 3D vector data.""" 

106 if validate and len(data) < offset + 12: 

107 raise InsufficientDataError("3D vector", data[offset:], 12) 

108 

109 x_axis = DataParser.parse_float32(data, offset) 

110 y_axis = DataParser.parse_float32(data, offset + 4) 

111 z_axis = DataParser.parse_float32(data, offset + 8) 

112 

113 return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis) 

114 

115 def encode_value(self, value: VectorData, *, validate: bool = True) -> bytearray: 

116 """Encode 3D vector data to bytes.""" 

117 result = bytearray() 

118 result.extend(DataParser.encode_float32(value.x_axis)) 

119 result.extend(DataParser.encode_float32(value.y_axis)) 

120 result.extend(DataParser.encode_float32(value.z_axis)) 

121 return result 

122 

123 

124class Vector2DTemplate(CodingTemplate[Vector2DData]): 

125 """Template for 2D vector measurements (x, y float32 components).""" 

126 

127 @property 

128 def data_size(self) -> int: 

129 """Size: 8 bytes (2 x float32).""" 

130 return 8 

131 

132 def decode_value( 

133 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

134 ) -> Vector2DData: 

135 """Parse 2D vector data.""" 

136 if validate and len(data) < offset + 8: 

137 raise InsufficientDataError("2D vector", data[offset:], 8) 

138 

139 x_axis = DataParser.parse_float32(data, offset) 

140 y_axis = DataParser.parse_float32(data, offset + 4) 

141 

142 return Vector2DData(x_axis=x_axis, y_axis=y_axis) 

143 

144 def encode_value(self, value: Vector2DData, *, validate: bool = True) -> bytearray: 

145 """Encode 2D vector data to bytes.""" 

146 result = bytearray() 

147 result.extend(DataParser.encode_float32(value.x_axis)) 

148 result.extend(DataParser.encode_float32(value.y_axis)) 

149 return result