Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""Explicit, Home-Assistant-free settings object for NestQuest core.
|
|
|
|
The integration reads its configuration off a Home Assistant config
|
|
entry (``entry.options``); the core package must not. This module
|
|
bridges that split: it defines a frozen, validation-carrying
|
|
:class:`NestQuestSettings` dataclass that the integration constructs
|
|
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
|
|
:meth:`NestQuestSettings.from_options_resilient` for the per-field
|
|
fallback used at setup) and then passes into the core paths that
|
|
previously read HA config themselves — the rolling horizon
|
|
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
|
|
plus the Feature 11 notification settings (notify target, the three
|
|
summary/reminder/report times, and the four independent enable toggles).
|
|
|
|
Nothing in this module imports ``homeassistant`` or touches a config
|
|
entry; ``from_options`` accepts any plain mapping keyed by the
|
|
:data:`~.const.CONF_*` names. The validation semantics match the
|
|
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
|
|
rejected), and ``day_rollover_time`` plus the three notification times
|
|
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
|
|
never yield a settings object whose horizon could shrink or explode the
|
|
materialized window, or whose rollover fires at an unparsable time.
|
|
|
|
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
|
|
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
|
|
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
|
|
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
|
|
:class:`NestQuestSettings` cannot smuggle a ``None`` past
|
|
:meth:`__post_init__`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Any, Mapping
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from .const import (
|
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
|
CONF_AFTERNOON_REMINDER_TIME,
|
|
CONF_CELEBRATION_ENABLED,
|
|
CONF_DAY_ROLLOVER_TIME,
|
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
|
CONF_END_OF_DAY_REPORT_TIME,
|
|
CONF_HORIZON_DAYS,
|
|
CONF_MORNING_SUMMARY_ENABLED,
|
|
CONF_MORNING_SUMMARY_TIME,
|
|
CONF_NOTIFY_TARGET,
|
|
CONF_TIMEZONE,
|
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
|
DEFAULT_AUTOMATION_ENABLED,
|
|
DEFAULT_DAY_ROLLOVER_TIME,
|
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
|
DEFAULT_HORIZON_DAYS,
|
|
DEFAULT_MORNING_SUMMARY_TIME,
|
|
DEFAULT_TIMEZONE,
|
|
LOGGER,
|
|
TIME_PATTERN,
|
|
)
|
|
|
|
|
|
def _option_value(
|
|
opts: Mapping[str, Any], key: str, default: Any
|
|
) -> Any:
|
|
"""Return ``opts[key]`` when present and non-None, else ``default``.
|
|
|
|
This is the SOLE place the ``None``-as-absent mapping lives: a stored
|
|
``None`` is treated the same as an absent key — the historical
|
|
``horizon_days`` path mapped a missing-or-None value to the default,
|
|
and this keeps that forgiving semantics uniform across every field.
|
|
The per-field resolvers (:func:`_resolve_horizon_days`,
|
|
:func:`_validate_hhmm`, :func:`_resolve_bool`,
|
|
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
|
|
so a partially-populated ``entry.options`` dict (or one migrated
|
|
across versions) never raises just because a key carries ``None``,
|
|
and a hand-built :class:`NestQuestSettings` cannot smuggle a
|
|
``None`` past :meth:`NestQuestSettings.__post_init__`.
|
|
"""
|
|
if key not in opts:
|
|
return default
|
|
value = opts[key]
|
|
return default if value is None else value
|
|
|
|
|
|
def _validate_hhmm(value: Any, *, field: str) -> str:
|
|
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
|
|
|
|
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
|
|
regex the options flow applies), so a stored option and a freshly
|
|
validated form submit parse the same way. ``None`` (and any
|
|
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
|
|
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
|
"""
|
|
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
|
|
raise ValueError(
|
|
f"{field} must be a strict HH:MM string, got {value!r}"
|
|
)
|
|
return value
|
|
|
|
|
|
def _resolve_horizon_days(value: Any) -> int:
|
|
"""Validate a horizon value and return the resolved day count.
|
|
|
|
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
|
|
non-int is rejected, and a sub-1 value is rejected — so a malformed
|
|
option can never shrink or explode the materialized horizon.
|
|
``None`` is rejected here too; the ``None``-as-absent mapping lives
|
|
ONLY in :meth:`NestQuestSettings.from_options` (via
|
|
:func:`_option_value`), so a hand-built
|
|
:class:`NestQuestSettings(horizon_days=None)` raises in
|
|
:meth:`NestQuestSettings.__post_init__` rather than silently
|
|
constructing an object whose :meth:`NestQuestSettings.horizon_window`
|
|
would later raise ``TypeError`` on ``timedelta(days=None)``.
|
|
"""
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
raise ValueError(
|
|
f"horizon_days must be a positive integer, got {value!r}"
|
|
)
|
|
return value
|
|
|
|
|
|
def _resolve_bool(value: Any) -> bool:
|
|
"""Return a real bool; a non-bool (including ``None``) is rejected.
|
|
|
|
voluptuous ``bool`` would coerce strings, so the options flow uses a
|
|
strict-bool validator and this mirrors it. The ``None``-as-absent
|
|
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
|
|
:func:`_option_value`).
|
|
"""
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f"expected a boolean, got {value!r}")
|
|
return value
|
|
|
|
|
|
def _resolve_optional_str(value: Any) -> str:
|
|
"""Return a stripped notify target string; a non-str is rejected.
|
|
|
|
A padded or whitespace-only value is STRIPPED to its trim — the
|
|
shipped default is the empty string, meaning "no notifications are
|
|
configured yet". This is intentionally MORE LENIENT than the options
|
|
flow, which REJECTS a target that differs from its own trim (so a
|
|
padded value never persists): the integration tolerates a stored
|
|
padded value (e.g. one migrated from an older schema) by normalizing
|
|
it rather than crashing setup. The ``None``-as-absent mapping lives
|
|
ONLY in :meth:`NestQuestSettings.from_options` (via
|
|
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
|
|
applies this SAME normalizer so a hand-built
|
|
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
|
|
exactly like the options-flow path (no padded value can slip through
|
|
direct construction either).
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"notify_target must be a string, got {value!r}")
|
|
return value.strip()
|
|
|
|
|
|
def _resolve_timezone(value: Any) -> str:
|
|
"""Validate a household time zone and return it.
|
|
|
|
The empty string means "the API host's local time" (the historical
|
|
behaviour); anything else must be an IANA key :class:`ZoneInfo`
|
|
resolves (e.g. ``America/New_York``). A non-str (including
|
|
``None``) is rejected; the ``None``-as-absent mapping lives ONLY in
|
|
:meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"timezone must be a string, got {value!r}")
|
|
if value == "":
|
|
return value
|
|
try:
|
|
ZoneInfo(value)
|
|
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
raise ValueError(
|
|
f"timezone must be an IANA time zone name such as "
|
|
f"'America/New_York', got {value!r}"
|
|
) from error
|
|
return value
|
|
|
|
|
|
#: The single field table the two builders share. Each row is
|
|
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
|
#: applies every validator (raising on the first invalid value) while
|
|
#: ``from_options_resilient`` wraps each in its own try/except so a single
|
|
#: bad sibling falls back to its default alone. Driving both builders
|
|
#: from this table avoids three-way drift between ``__post_init__``'s
|
|
#: inline checks and the two option builders' field lists: the only
|
|
#: places that enumerate the fields are this table and
|
|
#: ``__post_init__`` (which validates a hand-built instance without the
|
|
#: None->default mapping, so it stays inline).
|
|
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
|
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
|
|
(
|
|
"day_rollover_time",
|
|
CONF_DAY_ROLLOVER_TIME,
|
|
DEFAULT_DAY_ROLLOVER_TIME,
|
|
lambda v: _validate_hhmm(v, field="day_rollover_time"),
|
|
),
|
|
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
|
|
(
|
|
"morning_summary_time",
|
|
CONF_MORNING_SUMMARY_TIME,
|
|
DEFAULT_MORNING_SUMMARY_TIME,
|
|
lambda v: _validate_hhmm(v, field="morning_summary_time"),
|
|
),
|
|
(
|
|
"afternoon_reminder_time",
|
|
CONF_AFTERNOON_REMINDER_TIME,
|
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
|
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
|
|
),
|
|
(
|
|
"end_of_day_report_time",
|
|
CONF_END_OF_DAY_REPORT_TIME,
|
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
|
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
|
|
),
|
|
(
|
|
"morning_summary_enabled",
|
|
CONF_MORNING_SUMMARY_ENABLED,
|
|
DEFAULT_AUTOMATION_ENABLED,
|
|
_resolve_bool,
|
|
),
|
|
(
|
|
"afternoon_reminder_enabled",
|
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
|
DEFAULT_AUTOMATION_ENABLED,
|
|
_resolve_bool,
|
|
),
|
|
(
|
|
"end_of_day_report_enabled",
|
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
|
DEFAULT_AUTOMATION_ENABLED,
|
|
_resolve_bool,
|
|
),
|
|
(
|
|
"celebration_enabled",
|
|
CONF_CELEBRATION_ENABLED,
|
|
DEFAULT_AUTOMATION_ENABLED,
|
|
_resolve_bool,
|
|
),
|
|
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NestQuestSettings:
|
|
"""Explicit, HA-free configuration carried into core paths.
|
|
|
|
The integration builds this once from ``entry.options`` (via
|
|
:meth:`from_options`, or :meth:`from_options_resilient` for the
|
|
per-field fallback used at setup) and threads it into the horizon
|
|
materialization and the daily-rollover scheduling. Core never
|
|
reads HA config itself; callers pass the resolved horizon and
|
|
rollover time in.
|
|
|
|
All fields are validated at construction; the dataclass is frozen so
|
|
a stale settings object cannot be mutated in place after the
|
|
integration hands it off.
|
|
"""
|
|
|
|
horizon_days: int = DEFAULT_HORIZON_DAYS
|
|
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
|
|
notify_target: str = ""
|
|
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
|
|
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
|
|
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
|
|
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
|
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
|
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
|
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
|
timezone: str = DEFAULT_TIMEZONE
|
|
|
|
def __post_init__(self) -> None:
|
|
# Validate STRICTLY at construction so a hand-built instance
|
|
# cannot carry an invalid value: the same rules from_options
|
|
# applies, but WITHOUT the None->default mapping (that lives
|
|
# only in from_options via _option_value). A None horizon, time,
|
|
# or toggle raises here rather than later blowing up
|
|
# horizon_window or the rollover listener.
|
|
_resolve_horizon_days(self.horizon_days)
|
|
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
|
# ``notify_target`` is normalised the SAME way as from_options
|
|
# (via _resolve_optional_str): a non-str is rejected and a
|
|
# padded/whitespace-only value is STRIPPED to its trim. This
|
|
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
|
|
# behaving like the options flow (which strips on persist) so a
|
|
# padded value cannot slip through direct construction either.
|
|
# The dataclass is frozen, so the stripped value is written via
|
|
# object.__setattr__.
|
|
stripped_notify_target = _resolve_optional_str(self.notify_target)
|
|
object.__setattr__(self, "notify_target", stripped_notify_target)
|
|
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
|
|
_validate_hhmm(
|
|
self.afternoon_reminder_time, field="afternoon_reminder_time"
|
|
)
|
|
_validate_hhmm(
|
|
self.end_of_day_report_time, field="end_of_day_report_time"
|
|
)
|
|
for name in (
|
|
"morning_summary_enabled",
|
|
"afternoon_reminder_enabled",
|
|
"end_of_day_report_enabled",
|
|
"celebration_enabled",
|
|
):
|
|
value = getattr(self, name)
|
|
if not isinstance(value, bool):
|
|
raise ValueError(
|
|
f"{name} must be a boolean, got {value!r}"
|
|
)
|
|
_resolve_timezone(self.timezone)
|
|
|
|
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
|
|
"""Return the aware ``instant`` expressed in the household's zone.
|
|
|
|
The API service does not run inside Home Assistant, so its one
|
|
clock read (an aware host-local ``now``) is converted here: with
|
|
:attr:`timezone` set — only ever by an admin, from the admin
|
|
app — the SAME instant is re-expressed in that zone, so
|
|
``.date()`` and ``.time()`` are the household's wall clock even
|
|
when the host runs UTC. With :attr:`timezone` empty the instant
|
|
is returned unchanged (the host's local time).
|
|
"""
|
|
if not self.timezone:
|
|
return instant
|
|
return instant.astimezone(ZoneInfo(self.timezone))
|
|
|
|
@property
|
|
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
|
"""The (hour, minute) the daily rollover fires at."""
|
|
# The string is validated at construction, so a plain partition
|
|
# is safe and avoids re-running the regex on every access.
|
|
hour_str, _, minute_str = self.day_rollover_time.partition(":")
|
|
return int(hour_str), int(minute_str)
|
|
|
|
def horizon_window(
|
|
self, today: datetime.date
|
|
) -> tuple[datetime.date, datetime.date]:
|
|
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
|
|
|
``today`` is REQUIRED (there is no host-clock default): the
|
|
API passes the household-local date (:meth:`household_now`) so
|
|
the horizon tracks the household's own day, and a silent
|
|
host-clock fallback would be a wrong-day footgun (a test or a
|
|
caller that forgets to pass ``today`` would otherwise materialize
|
|
against the machine's calendar, not the household's). The
|
|
returned window is closed/inclusive — the same shape
|
|
:func:`~.materialize.materialize` walks.
|
|
"""
|
|
end = today + datetime.timedelta(days=self.horizon_days)
|
|
return today, end
|
|
|
|
@classmethod
|
|
def from_options(
|
|
cls, options: Mapping[str, Any] | None = None
|
|
) -> "NestQuestSettings":
|
|
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
|
|
|
|
Reads the same keys the options flow persists, applying the
|
|
matching defaults when a key is absent OR ``None`` (via
|
|
:func:`_option_value`, the SOLE place the ``None``-as-absent
|
|
mapping lives) and validating STRICTLY: ``horizon_days`` is a
|
|
positive int (bool rejected), the four times are strict
|
|
``HH:MM``, the four toggles are real booleans, and
|
|
``notify_target`` is a string (stripped). A malformed value
|
|
RAISES — the integration uses :meth:`from_options_resilient` at
|
|
setup to fall back per field instead of crashing. A ``None``
|
|
mapping yields the all-defaults settings — useful for tests and
|
|
for a fresh install whose options dict is empty.
|
|
|
|
``options`` is consumed read-only via
|
|
:class:`types.MappingProxyType`, so this method never mutates
|
|
the caller's mapping.
|
|
"""
|
|
opts: Mapping[str, Any] = (
|
|
MappingProxyType({}) if options is None else MappingProxyType(options)
|
|
)
|
|
return cls(
|
|
**{
|
|
field: validator(_option_value(opts, key, default))
|
|
for field, key, default, validator in _FIELDS
|
|
}
|
|
)
|
|
|
|
@classmethod
|
|
def from_options_resilient(
|
|
cls, options: Mapping[str, Any] | None = None
|
|
) -> "NestQuestSettings":
|
|
"""Build settings, falling back PER FIELD on a malformed value.
|
|
|
|
Unlike :meth:`from_options` (which raises on the first invalid
|
|
field), this resolves EACH field in its own try/except: a
|
|
malformed value logs a :data:`~.const.LOGGER` warning naming the
|
|
field and falls back to that field's default. The integration
|
|
uses this at setup so a single bad sibling option (e.g. a
|
|
migrated ``morning_summary_time`` of ``'25:00'``) cannot
|
|
silently reset the user's ``horizon_days`` or
|
|
``day_rollover_time`` — the two fields core actually consumes.
|
|
|
|
An absent or ``None`` key maps to the default via
|
|
:func:`_option_value` WITHOUT a warning (that is the normal
|
|
missing-key path); only a present, non-``None``, invalid value
|
|
triggers a warning and a per-field fallback.
|
|
"""
|
|
opts: Mapping[str, Any] = (
|
|
MappingProxyType({}) if options is None else MappingProxyType(options)
|
|
)
|
|
resolved: dict[str, Any] = {}
|
|
for field, key, default, validator in _FIELDS:
|
|
raw = _option_value(opts, key, default)
|
|
try:
|
|
resolved[field] = validator(raw)
|
|
except ValueError as err:
|
|
LOGGER.warning(
|
|
"NestQuest option %s is invalid (%s); falling back "
|
|
"to the default %r",
|
|
field,
|
|
err,
|
|
default,
|
|
)
|
|
resolved[field] = default
|
|
return cls(**resolved)
|