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

64 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

1"""Device Time characteristic (0x2B90). 

2 

3Per DTS v1.0 Table 3.6, this characteristic uses epoch-based Base_Time 

4(uint32 seconds since 1900-01-01 or 2000-01-01 per DT_Status bit 4), NOT 

5the 7-byte Date-Time format used by other time characteristics. 

6 

7Mandatory field layout (8 octets minimum, without E2E_CRC prefix): 

8 Offset Field Type Octets Unit 

9 0 Base_Time uint32 4 seconds since epoch 

10 4 Time_Zone sint8 1 15-minute units (-48..+56) 

11 5 DST_Offset uint8 1 0=Std, 2=+0.5h, 4=+1h, 8=+2h, 255=Unknown 

12 6 DT_Status uint16 2 status bitfield (Table 3.7) 

13 

14Optional trailing fields (present when corresponding feature flags are set): 

15 User_Time (uint32), Accumulated_RTC_Drift (uint16), 

16 Next_Sequence_Number (uint16), Base_Time_Second_Fractions (uint16). 

17 

18Total: 8-20 octets (without E2E_CRC). 

19 

20References: 

21 Bluetooth SIG Device Time Service v1.0, Table 3.6, Table 3.7 

22""" 

23 

24from __future__ import annotations 

25 

26from enum import IntFlag 

27 

28import msgspec 

29 

30from ..context import CharacteristicContext 

31from .base import BaseCharacteristic 

32from .utils import DataParser 

33 

34_MIN_LENGTH = 8 

35 

36 

37class DTStatus(IntFlag): 

38 """DT_Status bitfield — DTS v1.0 Table 3.7. 

39 

40 Bits 7-15 are Reserved for Future Use. 

41 """ 

42 

43 TIME_FAULT = 0x0001 # Bit 0 

44 UTC_ALIGNED = 0x0002 # Bit 1 

45 QUALIFIED_LOCAL_TIME_SYNCHRONIZED = 0x0004 # Bit 2 

46 PROPOSE_TIME_UPDATE_REQUEST = 0x0008 # Bit 3 

47 EPOCH_YEAR_2000 = 0x0010 # Bit 4 

48 NON_LOGGED_TIME_CHANGE_ACTIVE = 0x0020 # Bit 5 

49 LOG_CONSOLIDATION_ACTIVE = 0x0040 # Bit 6 

50 

51 

52class DeviceTimeData(msgspec.Struct, frozen=True, kw_only=True): 

53 """Parsed data from Device Time characteristic. 

54 

55 Attributes: 

56 base_time: Seconds since epoch (1900 or 2000 per DTStatus.EPOCH_YEAR_2000). 

57 time_zone: Offset in 15-minute units (sint8, -48..+56). 

58 dst_offset: DST offset (0=Std, 2=+0.5h, 4=+1h, 8=+2h, 255=Unknown). 

59 dt_status: Device Time status bitfield (Table 3.7). 

60 user_time: Optional seconds since epoch for user-facing display time. 

61 accumulated_rtc_drift: Optional accumulated RTC drift in seconds. 

62 next_sequence_number: Optional next time-change log sequence number. 

63 base_time_second_fractions: Optional sub-second fractions (1/65536 s). 

64 """ 

65 

66 base_time: int 

67 time_zone: int 

68 dst_offset: int 

69 dt_status: DTStatus 

70 user_time: int | None = None 

71 accumulated_rtc_drift: int | None = None 

72 next_sequence_number: int | None = None 

73 base_time_second_fractions: int | None = None 

74 

75 

76class DeviceTimeCharacteristic(BaseCharacteristic[DeviceTimeData]): 

77 """Device Time characteristic (0x2B90). 

78 

79 org.bluetooth.characteristic.device_time 

80 

81 Contains epoch-based Base_Time (uint32), Time_Zone (sint8), DST_Offset 

82 (uint8), and DT_Status (uint16 bitfield). Optional fields follow when 

83 their corresponding feature bits are set in the DT Feature characteristic. 

84 """ 

85 

86 min_length = _MIN_LENGTH 

87 allow_variable_length = True 

88 

89 def _decode_value( 

90 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True 

91 ) -> DeviceTimeData: 

92 base_time = DataParser.parse_int32(data, 0, signed=False) 

93 time_zone = DataParser.parse_int8(data, 4, signed=True) 

94 dst_offset = DataParser.parse_int8(data, 5, signed=False) 

95 dt_status = DTStatus(DataParser.parse_int16(data, 6, signed=False)) 

96 

97 offset = 8 

98 user_time: int | None = None 

99 accumulated_rtc_drift: int | None = None 

100 next_sequence_number: int | None = None 

101 base_time_second_fractions: int | None = None 

102 

103 if len(data) >= offset + 4: 

104 user_time = DataParser.parse_int32(data, offset, signed=False) 

105 offset += 4 

106 

107 if len(data) >= offset + 2: 

108 accumulated_rtc_drift = DataParser.parse_int16(data, offset, signed=False) 

109 offset += 2 

110 

111 if len(data) >= offset + 2: 

112 next_sequence_number = DataParser.parse_int16(data, offset, signed=False) 

113 offset += 2 

114 

115 if len(data) >= offset + 2: 

116 base_time_second_fractions = DataParser.parse_int16(data, offset, signed=False) 

117 

118 return DeviceTimeData( 

119 base_time=base_time, 

120 time_zone=time_zone, 

121 dst_offset=dst_offset, 

122 dt_status=dt_status, 

123 user_time=user_time, 

124 accumulated_rtc_drift=accumulated_rtc_drift, 

125 next_sequence_number=next_sequence_number, 

126 base_time_second_fractions=base_time_second_fractions, 

127 ) 

128 

129 def _encode_value(self, data: DeviceTimeData) -> bytearray: 

130 result = bytearray() 

131 result.extend(DataParser.encode_int32(data.base_time, signed=False)) 

132 result.extend(DataParser.encode_int8(data.time_zone, signed=True)) 

133 result.extend(DataParser.encode_int8(data.dst_offset, signed=False)) 

134 result.extend(DataParser.encode_int16(int(data.dt_status), signed=False)) 

135 if data.user_time is not None: 

136 result.extend(DataParser.encode_int32(data.user_time, signed=False)) 

137 if data.accumulated_rtc_drift is not None: 

138 result.extend(DataParser.encode_int16(data.accumulated_rtc_drift, signed=False)) 

139 if data.next_sequence_number is not None: 

140 result.extend(DataParser.encode_int16(data.next_sequence_number, signed=False)) 

141 if data.base_time_second_fractions is not None: 

142 result.extend(DataParser.encode_int16(data.base_time_second_fractions, signed=False)) 

143 return result