Added Alexa Music
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# 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
|
||||
)
|
||||
Reference in New Issue
Block a user