# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. # Copyright (C) 2026 Lukas Bandura # SPDX-License-Identifier: AGPL-3.0-or-later # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Pure notification DECISION predicates. Single source of truth for the "should this notification fire now?" logic that carries real thresholds (quiet hours, milestone crossings, the pre-completion window). Both the live integration (``manager.WashDataManager`` - which keeps all DELIVERY: hass services, notify entities, quiet-hours queueing, presence) and the Playground simulation (which surfaces "a notification would fire here" markers) call these, so the panel's what-if timeline matches the running integration. Nothing here touches Home Assistant; every function is pure given plain config values + a timestamp, so it is executor-safe. Trivial "is a start/finish service configured" checks stay inline in the manager (they are config presence checks, not duplicated logic). Extracted verbatim from ``manager.py``; guarded by the notification test suite. """ from __future__ import annotations from datetime import datetime, timedelta from typing import Any from .const import CONF_NOTIFY_QUIET_END_HOUR, CONF_NOTIFY_QUIET_START_HOUR def quiet_hours_bounds(options: Any) -> tuple[int, int] | None: """Return validated ``(start_hour, end_hour)`` or ``None`` when off. Off when either hour is unset/None/non-int/out-of-range, or ``start == end``. """ raw_start = options.get(CONF_NOTIFY_QUIET_START_HOUR) raw_end = options.get(CONF_NOTIFY_QUIET_END_HOUR) if raw_start is None or raw_end is None: return None try: if isinstance(raw_start, bool) or isinstance(raw_end, bool): return None start = int(raw_start) end = int(raw_end) if start != raw_start or end != raw_end: return None except (TypeError, ValueError): return None if not (0 <= start <= 23) or not (0 <= end <= 23): return None if start == end: # Zero-length window -> feature off (avoids "always quiet" ambiguity). return None return start, end def in_quiet_hours(bounds: tuple[int, int] | None, when: datetime) -> bool: """Return True when ``when`` falls inside the quiet window ``bounds``. Supports windows that wrap midnight (start > end, e.g. 22 -> 7 means 22:00-06:59). The end hour is exclusive at hour granularity. ``when`` is supplied by the caller (the manager passes ``dt_util.now()``; the Playground passes the replay timestamp), so this never reads the wall clock. """ if bounds is None: return False start, end = bounds hour = when.hour if start < end: # Same-day window, e.g. 1 -> 6 covers hours 1..5. return start <= hour < end # Wrap-around window, e.g. 22 -> 7 covers 22,23,0..6. return hour >= start or hour < end def seconds_until_quiet_end( bounds: tuple[int, int] | None, when: datetime ) -> float: """Seconds from ``when`` until the next end-of-quiet boundary (end:00). Returns 0.0 when the feature is off or ``when`` is not in quiet hours. """ if bounds is None: return 0.0 if not in_quiet_hours(bounds, when): return 0.0 _start, end = bounds target = when.replace(hour=end, minute=0, second=0, microsecond=0) if target <= when: # End hour is earlier today (wrap-around window) -> it lands tomorrow. target = target + timedelta(days=1) return max(0.0, (target - when).total_seconds()) def milestone_crossed(prev_count: int, cur_count: int, milestones: Any) -> int | None: """Return the milestone just crossed, or None. A milestone ``m`` is crossed when ``prev_count < m <= cur_count``. Empty or malformed ``milestones`` is a no-op. If several are crossed in one step the largest is returned so a single, most-significant notification fires. """ if not milestones or isinstance(milestones, (str, bytes)): return None try: iterator = list(milestones) except TypeError: return None crossed: int | None = None for raw in iterator: # Accept genuine positive integers only: reject bool (True/False), fractional # floats (50.5 -> 50), and int-like strings ("50") so a milestone the user # never configured can't fire. if isinstance(raw, bool): continue try: m = int(raw) except (TypeError, ValueError): continue if m != raw or m <= 0: continue if prev_count < m <= cur_count and (crossed is None or m > crossed): crossed = m return crossed def should_notify_pre_completion( notify_before_end_minutes: float, already_notified: bool, time_remaining: float | None, cycle_progress: float, match_ambiguous: bool, ) -> bool: """The one-time "almost done" pre-completion gate. Fires when the configured lead time is set, we have not already fired, the model-estimated remaining time has dropped within the lead window, the cycle is not yet complete, and the match is not ambiguous. """ return ( notify_before_end_minutes > 0 and not already_notified and time_remaining is not None and time_remaining <= (notify_before_end_minutes * 60) and cycle_progress < 100 and not match_ambiguous )