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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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
11class TimeZoneCharacteristic(BaseCharacteristic):
12 """Time Zone characteristic (0x2A0E).
14 org.bluetooth.characteristic.time_zone
16 Time zone characteristic.
18 Represents the time difference in 15-minute increments between local
19 standard time and UTC.
20 """
22 # Manual override: YAML indicates sint8->int but we return descriptive strings
23 _manual_value_type: ValueType | str | None = ValueType.STRING
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")
30 # Parse sint8 value
31 offset_raw = int.from_bytes(data[:1], byteorder="little", signed=True)
33 # Handle special values
34 if offset_raw == SINT8_MIN:
35 return "Unknown"
37 # Validate range (-48 to +56 per specification)
38 if offset_raw < -48 or offset_raw > 56:
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 try:
78 offset_str = data[3:] # Remove "UTC" prefix
79 if offset_str[0] not in ["+", "-"]:
80 raise ValueError("Invalid timezone format")
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, 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 )
110 return bytearray(offset_raw.to_bytes(1, byteorder="little", signed=True))