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

52 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

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

2 

3from __future__ import annotations 

4 

5from ...types.gatt_enums import ValueType 

6from ..constants import SINT8_MIN 

7from ..context import CharacteristicContext 

8from .base import BaseCharacteristic 

9from .utils import DataParser 

10 

11 

12class TimeZoneCharacteristic(BaseCharacteristic[str]): 

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

14 

15 org.bluetooth.characteristic.time_zone 

16 

17 Time zone characteristic. 

18 

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

20 standard time and UTC. 

21 """ 

22 

23 # Manual override: YAML indicates sint8->int but we return descriptive strings 

24 _manual_value_type: ValueType | str | None = ValueType.STRING 

25 

26 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> str: 

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

28 if len(data) < 1: 

29 raise ValueError("Time zone data must be at least 1 byte") 

30 

31 # Parse sint8 value 

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

33 

34 # Handle special values 

35 if offset_raw == SINT8_MIN: 

36 return "Unknown" 

37 

38 # Validate range (-48 to +56 per specification) 

39 if offset_raw < -48 or offset_raw > 56: 

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

41 

42 # Convert 15-minute increments to hours and minutes 

43 # pylint: disable=duplicate-code 

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

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

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

47 total_minutes = offset_raw * 15 

48 hours = total_minutes // 60 

49 minutes = abs(total_minutes % 60) 

50 

51 # Format as UTC offset 

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

53 hours_abs = abs(hours) 

54 

55 if minutes == 0: 

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

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

58 

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

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

61 

62 Args: 

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

64 

65 Returns: 

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

67 

68 """ 

69 if isinstance(data, int): 

70 # Direct raw value 

71 offset_raw = data 

72 elif isinstance(data, str): 

73 # Parse string format 

74 if data == "Unknown": 

75 offset_raw = SINT8_MIN 

76 elif data.startswith("UTC"): 

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

78 try: 

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

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

81 raise ValueError("Invalid timezone format") 

82 

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

84 time_part = offset_str[1:] 

85 

86 if ":" in time_part: 

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

88 hours = int(hours_str) 

89 minutes = int(minutes_str) 

90 else: 

91 hours = int(time_part) 

92 minutes = 0 

93 

94 # Convert to 15-minute increments 

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

96 offset_raw = total_minutes // 15 

97 

98 except (ValueError, IndexError) as e: 

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

100 else: 

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

102 else: 

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

104 

105 # Validate range for sint8 (SINT8_MIN to SINT8_MAX, but spec says -48 to +56 + special SINT8_MIN) 

106 if offset_raw != SINT8_MIN and not -48 <= offset_raw <= 56: 

107 raise ValueError( 

108 f"Time zone offset {offset_raw} is outside valid range (-48 to +56, or SINT8_MIN for unknown)" 

109 ) 

110 

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