Apply PR #144 review fixes: per-field settings fallback, strict validation, hardened AST scan (task 4dd8f870)

P2-1: replace the all-or-nothing from_options try/except in setup with
from_options_resilient, which falls back PER FIELD (logging a warning
naming the bad field) so a malformed sibling (e.g. morning_summary_time
'25:00') cannot silently reset horizon_days/day_rollover_time.
P2-2: drop _run_horizon_materialization's legacy horizon_days keyword and
branch; default settings to NestQuestSettings(); remove the legacy paragraph.
P2-3: _run_horizon_materialization now calls settings.horizon_window(today)
so the helper is real and shared; extend tests to run the integration path
(_run_horizon_materialization + _register_day_rollover_listener) with
horizon_days=3 / day_rollover_time='03:30'.
P2-4: harden the AST scan to forbid hass/config_entry names, .hass/.entry/
.config_entry and .config/.options/.data attributes, and getattr/hasattr
calls with those string args; add a negative-control test for each shape.
P2-5: __post_init__ is strict (rejects None for every field); the
None->default mapping lives ONLY in from_options via _option_value.
P3: remove dead shims (_day_rollover_hour_minute, _configured_horizon_days)
and repoint test_lifecycle at from_options; de-dup the two HH:MM
validators into one using a shared TIME_PATTERN in core/const; make
horizon_window's today required; reword the notify_target docstring to
not claim options-flow parity; document _option_value's None-as-absent
semantics; remove unused test helpers.
This commit is contained in:
2026-09-21 15:37:12 -04:00
parent 345bd200e1
commit 33102f8806
6 changed files with 718 additions and 311 deletions
+15 -56
View File
@@ -19,7 +19,6 @@ from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
@@ -268,43 +267,10 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
return owners
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
"""Return the (hour, minute) the daily rollover fires at from ``options``.
Thin shim over :class:`NestQuestSettings`: the options flow validates
``day_rollover_time`` as a strict ``HH:MM`` string, so a stored value
never needs re-validation here; a missing key falls back to
:data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
"""
return NestQuestSettings.from_options(options).day_rollover_hour_minute
def _configured_horizon_days(options: dict[str, Any]) -> int:
"""Return the configured generation horizon, validated, else the default.
Thin shim over :class:`NestQuestSettings`. The settings object's
``from_options`` re-checks ``horizon_days`` — rejecting bools,
non-ints, and sub-1 values, falling back to
:data:`~.const.DEFAULT_HORIZON_DAYS` — so a malformed option can
never shrink or explode the materialized horizon.
"""
try:
return NestQuestSettings.from_options(options).horizon_days
except ValueError:
# A malformed stored option (a bad time string) must not crash
# startup; the horizon itself was already validated above, so a
# ValueError here can only come from a sibling time field — fall
# back to the default horizon the same way the pre-settings path
# tolerated partial options.
return DEFAULT_HORIZON_DAYS
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings | None = None,
*,
horizon_days: int | None = None,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
@@ -315,24 +281,18 @@ async def _run_horizon_materialization(
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. Callers SHOULD pass a
:class:`NestQuestSettings` instance built from ``entry.options``; the
legacy ``horizon_days`` keyword is kept for callers that have not yet
been converted and overrides the settings value when supplied. When
neither is given the window sizes to
:data:`~.const.DEFAULT_HORIZON_DAYS`.
no duplicated materialization logic. ``settings`` carries the entry's
validated ``horizon_days``; when omitted a default
:class:`NestQuestSettings` is used (its horizon is the configured
default). The window itself comes from :meth:`settings.horizon_window`
so the helper is real and shared (not recomputed inline here).
"""
if settings is not None:
horizon = settings.horizon_days
else:
horizon = horizon_days if horizon_days is not None else DEFAULT_HORIZON_DAYS
if settings is None:
settings = NestQuestSettings()
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start_date = today.isoformat()
end_date = (
today + datetime.timedelta(days=horizon)
).isoformat()
await _materialize_run(database, start_date, end_date, today=today)
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
@@ -507,13 +467,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back to the defaults (the same way the
# pre-settings path tolerated a partial options dict) rather than
# crashing setup.
try:
settings = NestQuestSettings.from_options(entry.options)
except ValueError:
settings = NestQuestSettings()
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot silently reset the user's
# horizon_days or day_rollover_time — the two fields core consumes
# — rather than the all-or-nothing reset a single try/except would
# cause (a malformed morning_summary_time used to wipe horizon_days).
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
@@ -2,11 +2,19 @@
from __future__ import annotations
import logging
import re
DOMAIN = "nestquest"
LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
#: settings object's time validation, so a stored option and a freshly
#: validated form submit parse the same way. Defined here (HA-free) so
#: both the integration's options flow and the core settings module
#: import ONE pattern instead of re-copying it.
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
# Platforms set up per config entry (Feature 10): the per-child day
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
+221 -98
View File
@@ -4,12 +4,13 @@ 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`) 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).
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
@@ -19,11 +20,18 @@ 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
import re
from collections.abc import Callable
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
@@ -45,25 +53,27 @@ from .const import (
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
LOGGER,
TIME_PATTERN,
)
#: Strict 24-hour ``HH:MM`` pattern — matches the options flow's
#: ``_TIME_PATTERN`` verbatim so a stored option and a freshly-validated
#: form submit parse the same way.
_HHMM_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
def _option_value(
opts: Mapping[str, Any], key: str, default: Any
) -> Any:
"""Return ``opts[key]`` when present and non-None, else ``default``.
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: a partially-populated ``entry.options`` dict (or one
migrated across versions) never raises just because a key carries
``None``.
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
@@ -71,31 +81,36 @@ def _option_value(
return default if value is None else value
def _parse_hhmm(value: str) -> tuple[int, int]:
"""Return ``(hour, minute)`` for a strict ``HH:MM`` string.
def _validate_hhmm(value: Any, *, field: str) -> str:
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
Raises :class:`ValueError` for anything that is not a string or does
not match :data:`_HHMM_PATTERN` — the same rule the options flow
applies, surfaced here so a malformed mapping cannot construct a
settings object with an unparsable time.
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 _HHMM_PATTERN.fullmatch(value) is None:
raise ValueError(f"expected a strict HH:MM string, got {value!r}")
hour_str, _, minute_str = value.partition(":")
return int(hour_str), int(minute_str)
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.
Mirrors :func:`~.materialize._resolve_horizon_days`: 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``
resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`.
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 value is None:
return DEFAULT_HORIZON_DAYS
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}"
@@ -103,30 +118,32 @@ def _resolve_horizon_days(value: Any) -> int:
return value
def _resolve_bool(value: Any, *, default: bool) -> bool:
"""Return a real bool, falling back to ``default`` when ``value`` is None.
def _resolve_bool(value: Any) -> bool:
"""Return a real bool; a non-bool (including ``None``) is rejected.
A non-bool, non-None value is rejected — voluptuous ``bool`` would
coerce strings, so the options flow uses ``_strict_bool`` and this
mirrors it.
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 value is None:
return default
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; empty means "not configured".
"""Return a stripped notify target string; a non-str is rejected.
A non-str value is rejected. An empty (or whitespace-only) value
is kept as the empty string — the shipped default, meaning no
notifications are configured yet — rather than raising, mirroring
the options flow's empty-target acceptance.
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`).
"""
if value is None:
return ""
if not isinstance(value, str):
raise ValueError(f"notify_target must be a string, got {value!r}")
return value.strip()
@@ -137,9 +154,11 @@ class NestQuestSettings:
"""Explicit, HA-free configuration carried into core paths.
The integration builds this once from ``entry.options`` (via
:meth:`from_options`) 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.
: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
@@ -158,17 +177,25 @@ class NestQuestSettings:
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
def __post_init__(self) -> None:
# Validate at construction so a hand-built instance cannot carry
# an invalid value either: the same rules from_options applies.
# 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,
# toggle, or notify target raises here rather than later blowing
# up horizon_window or the rollover listener.
_resolve_horizon_days(self.horizon_days)
_parse_hhmm(self.day_rollover_time)
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
if not isinstance(self.notify_target, str):
raise ValueError(
f"notify_target must be a string, got {self.notify_target!r}"
)
_parse_hhmm(self.morning_summary_time)
_parse_hhmm(self.afternoon_reminder_time)
_parse_hhmm(self.end_of_day_report_time)
_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",
@@ -184,22 +211,25 @@ class NestQuestSettings:
@property
def day_rollover_hour_minute(self) -> tuple[int, int]:
"""The (hour, minute) the daily rollover fires at."""
return _parse_hhmm(self.day_rollover_time)
# 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 | None = None
self, today: datetime.date
) -> tuple[datetime.date, datetime.date]:
"""Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` defaults to the host clock's date
(:func:`datetime.date.today`), matching the historical materialize
default. The integration overrides this with the HA-local
``hass.config.time_zone`` date so the horizon tracks the
household's own day. The returned window is closed/inclusive —
the same shape :func:`~.materialize.materialize` walks.
``today`` is REQUIRED (there is no host-clock default): the
integration passes the HA-local ``hass.config.time_zone`` date 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.
"""
if today is None:
today = datetime.date.today()
end = today + datetime.timedelta(days=self.horizon_days)
return today, end
@@ -210,12 +240,16 @@ class 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 and validating the same
way the options flow does: ``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.
A ``None`` mapping yields the all-defaults settings — useful for
tests and for a fresh install whose options dict is empty.
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
@@ -228,20 +262,24 @@ class NestQuestSettings:
horizon_days=_resolve_horizon_days(
_option_value(opts, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
),
day_rollover_time=_parse_hhmm_str(
_option_value(opts, CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
day_rollover_time=_validate_hhmm(
_option_value(
opts, CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME
),
field="day_rollover_time",
),
notify_target=_resolve_optional_str(
_option_value(opts, CONF_NOTIFY_TARGET, "")
),
morning_summary_time=_parse_hhmm_str(
morning_summary_time=_validate_hhmm(
_option_value(
opts, CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME
opts,
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
),
field="morning_summary_time",
),
afternoon_reminder_time=_parse_hhmm_str(
afternoon_reminder_time=_validate_hhmm(
_option_value(
opts,
CONF_AFTERNOON_REMINDER_TIME,
@@ -249,7 +287,7 @@ class NestQuestSettings:
),
field="afternoon_reminder_time",
),
end_of_day_report_time=_parse_hhmm_str(
end_of_day_report_time=_validate_hhmm(
_option_value(
opts,
CONF_END_OF_DAY_REPORT_TIME,
@@ -260,43 +298,128 @@ class NestQuestSettings:
morning_summary_enabled=_resolve_bool(
_option_value(
opts, CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED
),
default=DEFAULT_AUTOMATION_ENABLED,
)
),
afternoon_reminder_enabled=_resolve_bool(
_option_value(
opts,
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
),
default=DEFAULT_AUTOMATION_ENABLED,
)
),
end_of_day_report_enabled=_resolve_bool(
_option_value(
opts,
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
),
default=DEFAULT_AUTOMATION_ENABLED,
)
),
celebration_enabled=_resolve_bool(
_option_value(
opts, CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED
),
default=DEFAULT_AUTOMATION_ENABLED,
)
),
)
@classmethod
def from_options_resilient(
cls, options: Mapping[str, Any] | None = None
) -> "NestQuestSettings":
"""Build settings, falling back PER FIELD on a malformed value.
def _parse_hhmm_str(value: Any, *, field: str) -> str:
"""Return a validated ``HH:MM`` string, raising 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.
A separate helper from :func:`_parse_hhmm` because :meth:`from_options`
needs the original string kept (the dataclass stores strings, not
tuples), but still wants the same strict validation.
"""
if not isinstance(value, str) or _HHMM_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must be a strict HH:MM string, got {value!r}"
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)
)
return value
resolved: dict[str, Any] = {}
def _resolve(
field: str, key: str, default: Any, fn: Callable[[Any], Any]
) -> None:
raw = _option_value(opts, key, default)
try:
resolved[field] = fn(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
_resolve(
"horizon_days",
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
_resolve_horizon_days,
)
_resolve(
"day_rollover_time",
CONF_DAY_ROLLOVER_TIME,
DEFAULT_DAY_ROLLOVER_TIME,
lambda v: _validate_hhmm(v, field="day_rollover_time"),
)
_resolve(
"notify_target",
CONF_NOTIFY_TARGET,
"",
_resolve_optional_str,
)
_resolve(
"morning_summary_time",
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
lambda v: _validate_hhmm(v, field="morning_summary_time"),
)
_resolve(
"afternoon_reminder_time",
CONF_AFTERNOON_REMINDER_TIME,
DEFAULT_AFTERNOON_REMINDER_TIME,
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
)
_resolve(
"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"),
)
_resolve(
"morning_summary_enabled",
CONF_MORNING_SUMMARY_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"afternoon_reminder_enabled",
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"end_of_day_report_enabled",
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"celebration_enabled",
CONF_CELEBRATION_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
return cls(**resolved)
+2 -4
View File
@@ -1,7 +1,6 @@
"""Options flow for NestQuest post-setup settings."""
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
@@ -33,18 +32,17 @@ from .const import (
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
def _parse_time(value: str) -> bool:
"""Return True when value is a valid 24-hour HH:MM string."""
return _TIME_PATTERN.fullmatch(value) is not None
return TIME_PATTERN.fullmatch(value) is not None
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: