Coverage for src / bluetooth_sig / gatt / characteristics / time_zone.py: 78%

51 statements  

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

1"""Time Zone characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from ..constants import SINT8_MIN 

6from ..context import CharacteristicContext 

7from .base import BaseCharacteristic 

8from .utils import DataParser 

9 

10# Timezone offsets (15-minute increments from UTC) 

11TIMEZONE_OFFSET_MIN = -48 # Minimum timezone offset in 15-minute increments (UTC-12:00) 

12TIMEZONE_OFFSET_MAX = 56 # Maximum timezone offset in 15-minute increments (UTC+14:00) 

13 

14 

15class TimeZoneCharacteristic(BaseCharacteristic[str]): 

16 """Time Zone characteristic (0x2A0E). 

17 

18 org.bluetooth.characteristic.time_zone 

19 

20 Time zone characteristic. 

21 

22 Represents the time difference in 15-minute increments between local 

23 standard time and UTC. 

24 """ 

25 

26 min_length: int = 1 

27 

28 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: 

29 """Parse time zone data (sint8 in 15-minute increments from UTC).""" 

30 # Parse sint8 value 

31 offset_raw = DataParser.parse_int8(data, 0, signed=True) 

32 

33 # Handle special values 

34 if offset_raw == SINT8_MIN: 

35 return "Unknown" 

36 

37 # Validate range (TIMEZONE_OFFSET_MIN to TIMEZONE_OFFSET_MAX per specification) 

38 if offset_raw < TIMEZONE_OFFSET_MIN or offset_raw > TIMEZONE_OFFSET_MAX: 

39 return f"Reserved (value: {offset_raw})" 

40 

41 # Convert 15-minute increments to hours and minutes 

42 # pylint: disable=duplicate-code 

43 # NOTE: UTC offset formatting is shared with LocalTimeInformationCharacteristic. 

44 # Both use identical 15-minute increment conversion and formatting per Bluetooth SIG time spec. 

45 # Consolidation not practical as they're independent characteristics with different data structures. 

46 total_minutes = offset_raw * 15 

47 hours = total_minutes // 60 

48 minutes = abs(total_minutes % 60) 

49 

50 # Format as UTC offset 

51 sign = "+" if total_minutes >= 0 else "-" 

52 hours_abs = abs(hours) 

53 

54 if minutes == 0: 

55 return f"UTC{sign}{hours_abs:02d}:00" 

56 return f"UTC{sign}{hours_abs:02d}:{minutes:02d}" 

57 

58 def _encode_value(self, data: str | int) -> bytearray: 

59 """Encode time zone value back to bytes. 

60 

61 Args: 

62 data: Time zone offset either as string (e.g., "UTC+05:30") or as raw sint8 value 

63 

64 Returns: 

65 Encoded bytes representing the time zone (sint8, 15-minute increments) 

66 

67 """ 

68 if isinstance(data, int): 

69 # Direct raw value 

70 offset_raw = data 

71 elif isinstance(data, str): 

72 # Parse string format 

73 if data == "Unknown": 

74 offset_raw = SINT8_MIN 

75 elif data.startswith("UTC"): 

76 # Parse UTC offset format like "UTC+05:30" or "UTC-03:00" 

77 offset_str = data[3:] # Remove "UTC" prefix 

78 if not offset_str or offset_str[0] not in ["+", "-"]: 

79 raise ValueError("Invalid timezone format") 

80 

81 try: 

82 sign = 1 if offset_str[0] == "+" else -1 

83 time_part = offset_str[1:] 

84 

85 if ":" in time_part: 

86 hours_str, minutes_str = time_part.split(":") 

87 hours = int(hours_str) 

88 minutes = int(minutes_str) 

89 else: 

90 hours = int(time_part) 

91 minutes = 0 

92 

93 # Convert to 15-minute increments 

94 total_minutes = sign * (hours * 60 + minutes) 

95 offset_raw = total_minutes // 15 

96 

97 except (ValueError, IndexError) as e: 

98 raise ValueError(f"Invalid time zone format: {data}") from e 

99 else: 

100 raise ValueError(f"Invalid time zone format: {data}") 

101 else: 

102 raise TypeError("Time zone data must be a string or integer") 

103 

104 # Validate range for sint8 (SINT8_MIN to SINT8_MAX) 

105 # Spec allows TIMEZONE_OFFSET_MIN to TIMEZONE_OFFSET_MAX + special SINT8_MIN for unknown 

106 if offset_raw != SINT8_MIN and not TIMEZONE_OFFSET_MIN <= offset_raw <= TIMEZONE_OFFSET_MAX: 

107 raise ValueError( 

108 f"Time zone offset {offset_raw} is outside valid range " 

109 f"({TIMEZONE_OFFSET_MIN} to {TIMEZONE_OFFSET_MAX}, or SINT8_MIN for unknown)" 

110 ) 

111 

112 return bytearray(DataParser.encode_int8(offset_raw, signed=True))