Files
2026-07-17 10:12:15 -04:00

157 lines
5.9 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
"""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
)