Add the notification section to the options flow
Notify target (empty default, household fills their own service), three strict HH:MM automation times (08:00/15:00/20:00 — the report deliberately before the midnight sweep), and four independent enable toggles, all leniently stored and round-tripped.
This commit is contained in:
@@ -73,6 +73,26 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# Notification automations (Feature 11). The notify target is a free-text
|
||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||
# household fills in — never a hard-coded personal target; the times are
|
||||
# strict HH:MM local; each of the four automations has an independent
|
||||
# enable toggle. Per the owner-settled resolution the end-of-day report
|
||||
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
|
||||
# and never defaults to the rollover time.
|
||||
CONF_NOTIFY_TARGET = "notify_target"
|
||||
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
|
||||
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
|
||||
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
|
||||
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
|
||||
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
|
||||
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# each produces its own instance per day and groups the panel's Quest
|
||||
# Log into three columns. The clock ranges are stored here so they stay
|
||||
|
||||
@@ -11,12 +11,24 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
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_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
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_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
@@ -71,9 +83,47 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
):
|
||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||
|
||||
# The notification section (Feature 11) is OPTIONAL in raw submits
|
||||
# the same way: a missing key keeps the current value. The three
|
||||
# times are strict HH:MM; the notify target is a non-empty HA
|
||||
# service name; the toggles are real booleans.
|
||||
for time_key in (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
):
|
||||
time_value = user_input.get(time_key)
|
||||
if time_value is not None and (
|
||||
not isinstance(time_value, str) or not _parse_time(time_value)
|
||||
):
|
||||
errors[time_key] = "invalid_time"
|
||||
notify_target = user_input.get(CONF_NOTIFY_TARGET)
|
||||
if notify_target is not None and (
|
||||
not isinstance(notify_target, str)
|
||||
or not notify_target.strip()
|
||||
or notify_target != notify_target.strip()
|
||||
):
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
for toggle_key in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
toggle_value = user_input.get(toggle_key)
|
||||
if toggle_value is not None and not isinstance(toggle_value, bool):
|
||||
errors[toggle_key] = "invalid"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_schema(
|
||||
current: dict[str, Any],
|
||||
*,
|
||||
@@ -85,7 +135,10 @@ def _build_schema(
|
||||
The admin picker is a multi-select of EXISTING Home Assistant users
|
||||
(never a free-text field — the allowlist stores user IDs); its
|
||||
default is the current database allowlist, so an untouched submit
|
||||
re-saves exactly who is already allowed.
|
||||
re-saves exactly who is already allowed. The notification section
|
||||
(Feature 11) carries the household's notify target, the three
|
||||
automation times, and four enable toggles — defaults from
|
||||
:mod:`~.const`, never a hard-coded personal target.
|
||||
"""
|
||||
fields: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
@@ -100,6 +153,37 @@ def _build_schema(
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
default=current[CONF_MORNING_SUMMARY_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
default=current[CONF_AFTERNOON_REMINDER_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
default=current[CONF_END_OF_DAY_REPORT_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
default=current[CONF_MORNING_SUMMARY_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
default=current[CONF_CELEBRATION_ENABLED],
|
||||
): _strict_bool,
|
||||
}
|
||||
if admin_choices is not None:
|
||||
# HA's supported multi-select validator: the frontend can
|
||||
@@ -233,6 +317,23 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||
),
|
||||
}
|
||||
# The notification section (Feature 11) stores the household's
|
||||
# choices the same fall-back-to-current way, so raw submits
|
||||
# that predate the section keep their stored values.
|
||||
current = self._current_values()
|
||||
for notification_key in (
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
data[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
elif admin_default:
|
||||
@@ -254,6 +355,14 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||
(CONF_NOTIFY_TARGET, ""),
|
||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
|
||||
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
|
||||
+155
-18
@@ -19,6 +19,18 @@ from custom_components.nestquest.const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
@@ -36,6 +48,22 @@ VALID_INPUT = {
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
}
|
||||
|
||||
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
||||
#: fresh entry: the Feature 10 refresh interval and the Feature 11
|
||||
#: notification section fill from their defaults.
|
||||
FULL_INPUT = {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
CONF_NOTIFY_TARGET: "",
|
||||
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
@@ -131,10 +159,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert result["data"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_shows_form_initially() -> None:
|
||||
@@ -214,6 +239,10 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
@@ -222,6 +251,14 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: True,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
CONF_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
@@ -295,16 +332,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert set(result["data"]) == {
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert set(result["data"]) == set(FULL_INPUT)
|
||||
assert result["data"] == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
||||
@@ -327,10 +356,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
_run(registry.dispatch_options_update(entry))
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
assert entry.options == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert entry.options == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
@@ -649,3 +675,114 @@ def test_options_flow_omitted_picker_carries_allowlist_forward(
|
||||
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_notification_section_defaults() -> None:
|
||||
"""A bare submit fills the notification section from const defaults:
|
||||
08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight
|
||||
rollover sweep — an empty notify target, and all toggles enabled."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00"
|
||||
assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00"
|
||||
assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == ""
|
||||
for toggle in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
assert result["data"][toggle] is True
|
||||
|
||||
|
||||
def test_options_flow_notification_times_must_be_strict_hh_mm() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_time in ("9:00", "25:00", "08:0", "08-00", 800):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_MORNING_SUMMARY_TIME: bad_time,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_notify_target_must_be_clean_nonempty_string() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_target in ("", " notify.x ", 42):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_NOTIFY_TARGET: bad_target,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_toggles_must_be_real_booleans() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_CELEBRATION_ENABLED: "on",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_notification_stored_values_round_trip() -> None:
|
||||
"""Stored notification settings are the form's defaults and a raw
|
||||
submit without them keeps the stored values."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_dad",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:15",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "16:45",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:00",
|
||||
CONF_MORNING_SUMMARY_ENABLED: False,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: False,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False
|
||||
|
||||
# A raw submit carrying only the legacy keys keeps the stored
|
||||
# notification settings.
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False
|
||||
assert result["data"][CONF_CELEBRATION_ENABLED] is True
|
||||
|
||||
|
||||
def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
|
||||
"""The notify target's DEFAULT is the empty string: the household
|
||||
fills their own service name, and no personal target ships as a
|
||||
value anywhere in the const/flow defaults."""
|
||||
from custom_components.nestquest import const
|
||||
|
||||
assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target"
|
||||
# No const default carries a concrete notify service.
|
||||
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
|
||||
form = _run(_make_flow(_make_entry()).async_step_init(None))
|
||||
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
|
||||
|
||||
Reference in New Issue
Block a user