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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Time Zone characteristic implementation."""
3from __future__ import annotations
5from ..constants import SINT8_MIN
6from ..context import CharacteristicContext
7from .base import BaseCharacteristic
8from .utils import DataParser
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)
15class TimeZoneCharacteristic(BaseCharacteristic[str]):
16 """Time Zone characteristic (0x2A0E).
18 org.bluetooth.characteristic.time_zone
20 Time zone characteristic.
22 Represents the time difference in 15-minute increments between local
23 standard time and UTC.
24 """
26 min_length: int = 1
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)
33 # Handle special values
34 if offset_raw == SINT8_MIN:
35 return "Unknown"
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})"
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)
50 # Format as UTC offset
51 sign = "+" if total_minutes >= 0 else "-"
52 hours_abs = abs(hours)
54 if minutes == 0:
55 return f"UTC{sign}{hours_abs:02d}:00"
56 return f"UTC{sign}{hours_abs:02d}:{minutes:02d}"
58 def _encode_value(self, data: str | int) -> bytearray:
59 """Encode time zone value back to bytes.
61 Args:
62 data: Time zone offset either as string (e.g., "UTC+05:30") or as raw sint8 value
64 Returns:
65 Encoded bytes representing the time zone (sint8, 15-minute increments)
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")
81 try:
82 sign = 1 if offset_str[0] == "+" else -1
83 time_part = offset_str[1:]
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
93 # Convert to 15-minute increments
94 total_minutes = sign * (hours * 60 + minutes)
95 offset_raw = total_minutes // 15
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")
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 )
112 return bytearray(DataParser.encode_int8(offset_raw, signed=True))