diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 924898f..e81d78e 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -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 diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 32ddd3b..201d128 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -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] diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index af949b6..ba4dcc5 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -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] == ""