"""Sleep schedule.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, time, timedelta, timezone from enum import IntEnum from .utils import utcnow def _minutes_to_time(minutes: int) -> time: return time(hour=(minutes // 60) % 24, minute=minutes % 60) class DayOfWeek(IntEnum): """Day of week enum.""" SUNDAY = 0 MONDAY = 1 TUESDAY = 2 WEDNESDAY = 3 THURSDAY = 4 FRIDAY = 5 SATURDAY = 6 @classmethod def from_name(cls, name: str) -> DayOfWeek: """Return DayOfWeek from name.""" return cls[name.upper()] @classmethod def from_date(cls, dt: datetime) -> DayOfWeek: """Convert from Python's weekday() (Mon=0) to DayOfWeek (Sun=0).""" return cls((dt.weekday() + 1) % 7) @dataclass class SleepScheduleDay: """Sleep schedule day.""" day: DayOfWeek sleep_time: time wake_time: time is_enabled: bool @classmethod def from_named_dict(cls, name: str, data: dict) -> SleepScheduleDay: """Return SleepScheduleDay from named dict.""" return cls( day=DayOfWeek.from_name(name), sleep_time=_minutes_to_time(data["sleepTime"]), wake_time=_minutes_to_time(data["wakeTime"]), is_enabled=data["isEnabled"], ) @classmethod def from_indexed_dict(cls, data: dict) -> SleepScheduleDay: """Return SleepScheduleDay from indexed dict.""" return cls( day=DayOfWeek(data["dayOfWeek"]), sleep_time=_minutes_to_time(data["sleepTime"]), wake_time=_minutes_to_time(data["wakeTime"]), is_enabled=data["isEnabled"], ) @dataclass class SleepSchedule: """Sleep schedule.""" days: list[SleepScheduleDay] _cached_window: tuple[datetime, datetime] | None = field( default=None, init=False, repr=False ) @property def is_enabled(self) -> bool: """Return `True` if any day has sleep mode enabled.""" return any(day.is_enabled for day in self.days) @staticmethod def parse(raw: dict | list) -> SleepSchedule: """Parse a raw dict or list to a SleepSchedule.""" if isinstance(raw, dict): days = [ SleepScheduleDay.from_named_dict(name, data) for name, data in raw.items() ] elif isinstance(raw, list): days = [SleepScheduleDay.from_indexed_dict(day) for day in raw] else: raise TypeError(f"Unsupported schedule format: {type(raw)}") return SleepSchedule(days=sorted(days, key=lambda e: e.day)) @classmethod def from_timestamp( cls, sleep_mode_time: int, duration: timedelta, is_enabled: bool = True ) -> SleepSchedule | None: """Parse a sleep schedule from timestamp.""" if not sleep_mode_time: return None sleep_start = datetime.fromtimestamp(sleep_mode_time, timezone.utc) sleep_minutes = sleep_start.hour * 60 + sleep_start.minute wake_minutes = (sleep_minutes + int(duration.total_seconds() // 60)) % 1440 days = [ SleepScheduleDay( day=DayOfWeek(i), sleep_time=_minutes_to_time(sleep_minutes), wake_time=_minutes_to_time(wake_minutes), is_enabled=is_enabled, ) for i in range(7) ] return cls(days=days) def get_day(self, day: DayOfWeek) -> SleepScheduleDay | None: """Get day.""" return next((e for e in self.days if e.day == day), None) def get_window( self, dt: datetime | None = None ) -> tuple[datetime, datetime] | None: """Return the window for the given datetime or now, if empty.""" if not self.is_enabled: return None if dt is None: dt = utcnow() if (window := self._cached_window) and max(window) > dt: return window start = end = None for offset in range(-7, 8): day = dt + timedelta(days=offset) entry = self.get_day(DayOfWeek.from_date(day)) if entry is None or not entry.is_enabled: continue sleep_minutes = entry.sleep_time.hour * 60 + entry.sleep_time.minute wake_minutes = entry.wake_time.hour * 60 + entry.wake_time.minute start_of_day = datetime.combine(day.date(), time(), dt.tzinfo) if wake_minutes < sleep_minutes: # crosses midnight start = start_of_day - timedelta(minutes=1440 - sleep_minutes) else: start = start_of_day + timedelta(minutes=sleep_minutes) if dt >= start or end is None: end = start_of_day + timedelta(minutes=wake_minutes) if dt > max(start, end): continue break if start is None or end is None: self._cached_window = None return None self._cached_window = (start, end) return self._cached_window def is_active(self, dt: datetime | None = None) -> bool: """Return `True` if the window is active (sleeping) for the given datetime.""" if not (window := self.get_window(dt)): return False if dt is None: dt = utcnow() return window[0] <= dt < window[1]