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

51 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +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 

9 

10 

11class TimeZoneCharacteristic(BaseCharacteristic): 

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

13 

14 org.bluetooth.characteristic.time_zone 

15 

16 Time zone characteristic. 

17 

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

19 standard time and UTC. 

20 """ 

21 

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

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

24 

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

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

27 if len(data) < 1: 

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

29 

30 # Parse sint8 value 

31 offset_raw = int.from_bytes(data[:1], byteorder="little", signed=True) 

32 

33 # Handle special values 

34 if offset_raw == SINT8_MIN: 

35 return "Unknown" 

36 

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

38 if offset_raw < -48 or offset_raw > 56: 

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 try: 

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

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

80 raise ValueError("Invalid timezone format") 

81 

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, but spec says -48 to +56 + special SINT8_MIN) 

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

106 raise ValueError( 

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

108 ) 

109 

110 return bytearray(offset_raw.to_bytes(1, byteorder="little", signed=True))