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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Time Zone characteristic implementation."""
3from __future__ import annotations
5from ...types.gatt_enums import ValueType
6from ..constants import SINT8_MIN
7from ..context import CharacteristicContext
8from .base import BaseCharacteristic
9from .utils import DataParser
12class TimeZoneCharacteristic(BaseCharacteristic[str]):
13 """Time Zone characteristic (0x2A0E).
15 org.bluetooth.characteristic.time_zone
17 Time zone characteristic.
19 Represents the time difference in 15-minute increments between local
20 standard time and UTC.
21 """
23 # Manual override: YAML indicates sint8->int but we return descriptive strings
24 _manual_value_type: ValueType | str | None = ValueType.STRING
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")
31 # Parse sint8 value
32 offset_raw = DataParser.parse_int8(data, 0, signed=True)
34 # Handle special values
35 if offset_raw == SINT8_MIN:
36 return "Unknown"
38 # Validate range (-48 to +56 per specification)
39 if offset_raw < -48 or offset_raw > 56:
40 return f"Reserved (value: {offset_raw})"
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)
51 # Format as UTC offset
52 sign = "+" if total_minutes >= 0 else "-"
53 hours_abs = abs(hours)
55 if minutes == 0:
56 return f"UTC{sign}{hours_abs:02d}:00"
57 return f"UTC{sign}{hours_abs:02d}:{minutes:02d}"
59 def _encode_value(self, data: str | int) -> bytearray:
60 """Encode time zone value back to bytes.
62 Args:
63 data: Time zone offset either as string (e.g., "UTC+05:30") or as raw sint8 value
65 Returns:
66 Encoded bytes representing the time zone (sint8, 15-minute increments)
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")
83 sign = 1 if offset_str[0] == "+" else -1
84 time_part = offset_str[1:]
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
94 # Convert to 15-minute increments
95 total_minutes = sign * (hours * 60 + minutes)
96 offset_raw = total_minutes // 15
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")
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 )
111 return bytearray(DataParser.encode_int8(offset_raw, signed=True))