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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +0000
1"""Device Time characteristic (0x2B90).
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.
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)
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).
18Total: 8-20 octets (without E2E_CRC).
20References:
21 Bluetooth SIG Device Time Service v1.0, Table 3.6, Table 3.7
22"""
24from __future__ import annotations
26from enum import IntFlag
28import msgspec
30from ..context import CharacteristicContext
31from .base import BaseCharacteristic
32from .utils import DataParser
34_MIN_LENGTH = 8
37class DTStatus(IntFlag):
38 """DT_Status bitfield — DTS v1.0 Table 3.7.
40 Bits 7-15 are Reserved for Future Use.
41 """
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
52class DeviceTimeData(msgspec.Struct, frozen=True, kw_only=True):
53 """Parsed data from Device Time characteristic.
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 """
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
76class DeviceTimeCharacteristic(BaseCharacteristic[DeviceTimeData]):
77 """Device Time characteristic (0x2B90).
79 org.bluetooth.characteristic.device_time
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 """
86 min_length = _MIN_LENGTH
87 allow_variable_length = True
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))
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
103 if len(data) >= offset + 4:
104 user_time = DataParser.parse_int32(data, offset, signed=False)
105 offset += 4
107 if len(data) >= offset + 2:
108 accumulated_rtc_drift = DataParser.parse_int16(data, offset, signed=False)
109 offset += 2
111 if len(data) >= offset + 2:
112 next_sequence_number = DataParser.parse_int16(data, offset, signed=False)
113 offset += 2
115 if len(data) >= offset + 2:
116 base_time_second_fractions = DataParser.parse_int16(data, offset, signed=False)
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 )
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