"""POSIX TZ string parser for aioesphomeapi. Parses POSIX TZ strings (e.g., "EST5EDT,M3.2.0,M11.1.0") into a structured ParsedTimezone representation. This is a Python port of ESPHome's C++ parser in posix_tz.cpp, producing identical field values for all inputs. The parsed struct can be used to: - Pre-compute timezone data at codegen time (ESPHome Step 2) - Send pre-parsed timezone over protobuf (ESPHome Step 3) """ from __future__ import annotations from dataclasses import dataclass, field from enum import IntEnum class DSTRuleType(IntEnum): """Type of DST transition rule.""" NONE = 0 MONTH_WEEK_DAY = 1 # M format: Mm.w.d JULIAN_NO_LEAP = 2 # J format: Jn (day 1-365, Feb 29 not counted) DAY_OF_YEAR = 3 # Plain number: n (day 0-365, Feb 29 counted) @dataclass class DSTRule: """Rule for DST transition.""" time_seconds: int = ( 0 # Seconds after midnight (default 7200 = 2:00 AM set by parser) ) day: int = 0 # Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) type: DSTRuleType = DSTRuleType.NONE month: int = 0 # Month 1-12 (for MONTH_WEEK_DAY) week: int = 0 # Week 1-5, 5 = last (for MONTH_WEEK_DAY) day_of_week: int = 0 # Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) @dataclass class ParsedTimezone: """Parsed POSIX timezone information.""" std_offset_seconds: int = 0 # Standard time offset (positive = west of UTC) dst_offset_seconds: int = 0 # DST offset from UTC dst_start: DSTRule = field(default_factory=DSTRule) dst_end: DSTRule = field(default_factory=DSTRule) @property def has_dst(self) -> bool: """Check if this timezone has DST rules.""" return self.dst_start.type != DSTRuleType.NONE def _skip_tz_name(s: str, pos: int) -> int: """Skip a timezone name (letters or <...> quoted format). Returns new position after the name. Raises ValueError if name is invalid. """ if s[pos] == "<": # Angle-bracket quoted name: <+07>, <-03>, pos += 1 # skip '<' while pos < len(s) and s[pos] != ">": pos += 1 if pos >= len(s): msg = "Unterminated angle-bracket timezone name" raise ValueError(msg) pos += 1 # skip '>' return pos # Standard name: 3+ letters start = pos while pos < len(s) and s[pos].isalpha(): pos += 1 if pos - start < 3: msg = f"Timezone name must be at least 3 letters, got {pos - start}" raise ValueError(msg) return pos def _parse_offset(s: str, pos: int) -> tuple[int, int]: """Parse an offset in format [-]hh[:mm[:ss]]. Returns (offset_in_seconds, new_position). """ sign = 1 if pos < len(s) and s[pos] == "-": sign = -1 pos += 1 elif pos < len(s) and s[pos] == "+": pos += 1 hours, pos = _parse_uint(s, pos) minutes = 0 seconds = 0 if pos < len(s) and s[pos] == ":": pos += 1 minutes, pos = _parse_uint(s, pos) if pos < len(s) and s[pos] == ":": pos += 1 seconds, pos = _parse_uint(s, pos) return sign * (hours * 3600 + minutes * 60 + seconds), pos def _parse_uint(s: str, pos: int) -> tuple[int, int]: """Parse an unsigned integer from string at position. Returns (value, new_position). Raises ValueError if no digits are found at the given position. """ start = pos value = 0 while pos < len(s) and s[pos].isdigit(): value = value * 10 + int(s[pos]) pos += 1 if pos == start: msg = f"Expected digit at position {start}" raise ValueError(msg) return value, pos def _parse_dst_rule_m(s: str, pos: int, rule: DSTRule) -> int: """Parse an Mm.w.d body into ``rule``; return new position.""" rule.type = DSTRuleType.MONTH_WEEK_DAY pos += 1 rule.month, pos = _parse_uint(s, pos) if rule.month < 1 or rule.month > 12: msg = f"Month must be 1-12, got {rule.month}" raise ValueError(msg) if pos >= len(s) or s[pos] != ".": msg = "Expected '.' after month in M-format rule" raise ValueError(msg) pos += 1 rule.week, pos = _parse_uint(s, pos) if rule.week < 1 or rule.week > 5: msg = f"Week must be 1-5, got {rule.week}" raise ValueError(msg) if pos >= len(s) or s[pos] != ".": msg = "Expected '.' after week in M-format rule" raise ValueError(msg) pos += 1 rule.day_of_week, pos = _parse_uint(s, pos) if rule.day_of_week > 6: msg = f"Day of week must be 0-6, got {rule.day_of_week}" raise ValueError(msg) return pos def _parse_dst_rule_j(s: str, pos: int, rule: DSTRule) -> int: """Parse a Jn body (Julian day 1-365, no Feb 29) into ``rule``; return new position.""" rule.type = DSTRuleType.JULIAN_NO_LEAP pos += 1 rule.day, pos = _parse_uint(s, pos) if rule.day < 1 or rule.day > 365: msg = f"Julian day must be 1-365, got {rule.day}" raise ValueError(msg) return pos def _parse_dst_rule_n(s: str, pos: int, rule: DSTRule) -> int: """Parse a plain ``n`` body (day of year 0-365, counting Feb 29) into ``rule``.""" rule.type = DSTRuleType.DAY_OF_YEAR rule.day, pos = _parse_uint(s, pos) if rule.day > 365: msg = f"Day of year must be 0-365, got {rule.day}" raise ValueError(msg) return pos def _parse_dst_rule(s: str, pos: int) -> tuple[DSTRule, int]: """Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time]. Returns (DSTRule, new_position). Raises ValueError on invalid input. """ rule = DSTRule() if pos >= len(s): msg = "Unexpected end of string, expected DST rule" raise ValueError(msg) if s[pos] in ("M", "m"): pos = _parse_dst_rule_m(s, pos, rule) elif s[pos] in ("J", "j"): pos = _parse_dst_rule_j(s, pos, rule) elif s[pos].isdigit(): pos = _parse_dst_rule_n(s, pos, rule) else: msg = f"Expected DST rule (M, J, or digit), got '{s[pos]}'" raise ValueError(msg) # Parse optional /time suffix rule.time_seconds = 2 * 3600 # Default 02:00 if pos < len(s) and s[pos] == "/": pos += 1 rule.time_seconds, pos = _parse_offset(s, pos) return rule, pos def parse_posix_tz(tz_string: str) -> ParsedTimezone: """Parse a POSIX TZ string into a ParsedTimezone struct. Supports formats like: - "EST5" (simple offset, no DST) - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules) - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times) - "<+07>-7" (angle-bracket notation for special names) - "IST-5:30" (half-hour offsets) - "EST5EDT,J60,J300" (J-format: Julian day without leap day) - "EST5EDT,60,300" (plain day number: day of year with leap day) Args: tz_string: The POSIX TZ string to parse. Returns: ParsedTimezone with all fields populated. Raises: ValueError: If the string is empty or has invalid format. """ if not tz_string: msg = "Empty timezone string" raise ValueError(msg) result = ParsedTimezone() pos = 0 # Skip standard timezone name pos = _skip_tz_name(tz_string, pos) # Parse standard offset (required) if pos >= len(tz_string) or ( not tz_string[pos].isdigit() and tz_string[pos] != "+" and tz_string[pos] != "-" ): msg = "Expected offset after timezone name" raise ValueError(msg) result.std_offset_seconds, pos = _parse_offset(tz_string, pos) # Check for DST name if pos >= len(tz_string): return result # No DST # If next char is comma, there's no DST name but there are rules (invalid) if tz_string[pos] == ",": msg = "Comma after standard offset without DST name" raise ValueError(msg) # Check if there's something that looks like a DST name start if not tz_string[pos].isalpha() and tz_string[pos] != "<": return result # No DST, trailing characters ignored pos = _skip_tz_name(tz_string, pos) # Optional DST offset (default is std - 1 hour) if ( pos < len(tz_string) and tz_string[pos] != "," and (tz_string[pos].isdigit() or tz_string[pos] in ("+", "-")) ): result.dst_offset_seconds, pos = _parse_offset(tz_string, pos) else: result.dst_offset_seconds = result.std_offset_seconds - 3600 # Parse DST rules (required when DST name is present) if pos >= len(tz_string) or tz_string[pos] != ",": # DST name without rules - treat as no DST return result pos += 1 result.dst_start, pos = _parse_dst_rule(tz_string, pos) # Second rule is required per POSIX if pos >= len(tz_string) or tz_string[pos] != ",": msg = "Expected comma before second DST rule" raise ValueError(msg) pos += 1 result.dst_end, pos = _parse_dst_rule(tz_string, pos) return result