2026-09-13 18:26:09 -04:00
|
|
|
"""Options flow for NestQuest post-setup settings."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
from homeassistant import config_entries
|
|
|
|
|
from homeassistant.data_entry_flow import FlowResult
|
2026-09-15 17:13:32 -04:00
|
|
|
from homeassistant.helpers import config_validation as cv
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
from .const import (
|
2026-09-15 17:03:53 -04:00
|
|
|
CONF_ADMIN_USER_IDS,
|
2026-09-19 14:18:18 -04:00
|
|
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
|
|
|
|
CONF_AFTERNOON_REMINDER_TIME,
|
2026-09-23 05:12:27 -04:00
|
|
|
CONF_API_BASE_URL,
|
2026-09-19 14:18:18 -04:00
|
|
|
CONF_CELEBRATION_ENABLED,
|
2026-09-13 18:26:09 -04:00
|
|
|
CONF_DAY_ROLLOVER_TIME,
|
2026-09-19 14:18:18 -04:00
|
|
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
|
|
|
|
CONF_END_OF_DAY_REPORT_TIME,
|
2026-09-13 18:26:09 -04:00
|
|
|
CONF_HORIZON_DAYS,
|
2026-09-19 14:18:18 -04:00
|
|
|
CONF_MORNING_SUMMARY_ENABLED,
|
|
|
|
|
CONF_MORNING_SUMMARY_TIME,
|
|
|
|
|
CONF_NOTIFY_TARGET,
|
2026-09-13 18:26:09 -04:00
|
|
|
CONF_PANEL_IDLE_TIMEOUT,
|
2026-09-23 05:12:27 -04:00
|
|
|
CONF_PANEL_TOKEN,
|
2026-09-19 11:41:12 -04:00
|
|
|
CONF_UPDATE_INTERVAL,
|
2026-09-23 05:12:27 -04:00
|
|
|
DEFAULT_API_BASE_URL,
|
2026-09-19 14:18:18 -04:00
|
|
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
|
|
|
|
DEFAULT_AUTOMATION_ENABLED,
|
2026-09-13 18:26:09 -04:00
|
|
|
DEFAULT_DAY_ROLLOVER_TIME,
|
2026-09-19 14:18:18 -04:00
|
|
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
2026-09-13 18:26:09 -04:00
|
|
|
DEFAULT_HORIZON_DAYS,
|
2026-09-19 14:18:18 -04:00
|
|
|
DEFAULT_MORNING_SUMMARY_TIME,
|
2026-09-13 18:26:09 -04:00
|
|
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
2026-09-23 05:12:27 -04:00
|
|
|
DEFAULT_PANEL_TOKEN,
|
2026-09-19 11:41:12 -04:00
|
|
|
DEFAULT_UPDATE_INTERVAL,
|
2026-09-15 17:03:53 -04:00
|
|
|
DOMAIN,
|
2026-09-19 11:41:12 -04:00
|
|
|
MIN_UPDATE_INTERVAL,
|
2026-09-21 15:55:18 -04:00
|
|
|
TIME_PATTERN,
|
2026-09-13 18:26:09 -04:00
|
|
|
)
|
2026-09-15 17:03:53 -04:00
|
|
|
from .admin_allowlist import list_admin_ids, set_admin_ids
|
2026-09-23 05:12:27 -04:00
|
|
|
from .api_client import is_valid_base_url
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
MIN_HORIZON_DAYS = 1
|
|
|
|
|
MIN_PANEL_IDLE_TIMEOUT = 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_time(value: str) -> bool:
|
|
|
|
|
"""Return True when value is a valid 24-hour HH:MM string."""
|
2026-09-21 15:55:18 -04:00
|
|
|
return TIME_PATTERN.fullmatch(value) is not None
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|
|
|
|
"""Validate user input and return field-level errors instead of raising."""
|
|
|
|
|
errors: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
|
|
|
|
if (
|
|
|
|
|
isinstance(horizon_days, bool)
|
|
|
|
|
or not isinstance(horizon_days, int)
|
|
|
|
|
or horizon_days < MIN_HORIZON_DAYS
|
|
|
|
|
):
|
|
|
|
|
errors[CONF_HORIZON_DAYS] = "invalid"
|
|
|
|
|
|
|
|
|
|
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
|
|
|
|
|
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
|
|
|
|
|
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
|
|
|
|
|
|
|
|
|
|
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
|
|
|
|
if (
|
|
|
|
|
isinstance(panel_idle_timeout, bool)
|
|
|
|
|
or not isinstance(panel_idle_timeout, int)
|
|
|
|
|
or panel_idle_timeout < MIN_PANEL_IDLE_TIMEOUT
|
|
|
|
|
):
|
|
|
|
|
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
|
|
|
|
|
2026-09-19 11:41:12 -04:00
|
|
|
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
|
|
|
|
|
# submits: a missing key keeps the current value, so pre-Feature-10
|
|
|
|
|
# form payloads (and API callers) are unaffected. Schema-validated
|
|
|
|
|
# submits always carry it.
|
|
|
|
|
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
|
|
|
|
|
if update_interval is not None and (
|
|
|
|
|
isinstance(update_interval, bool)
|
|
|
|
|
or not isinstance(update_interval, int)
|
|
|
|
|
or update_interval < MIN_UPDATE_INTERVAL
|
|
|
|
|
):
|
|
|
|
|
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
|
|
|
|
|
2026-09-19 14:18:18 -04:00
|
|
|
# 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)
|
2026-09-19 14:20:13 -04:00
|
|
|
if notify_target is not None and not isinstance(notify_target, str):
|
|
|
|
|
errors[CONF_NOTIFY_TARGET] = "invalid"
|
|
|
|
|
elif isinstance(notify_target, str) and notify_target != (
|
|
|
|
|
notify_target.strip()
|
2026-09-19 14:18:18 -04:00
|
|
|
):
|
2026-09-19 14:20:13 -04:00
|
|
|
# An EMPTY target is valid — it means "no notifications
|
|
|
|
|
# configured yet" and is the shipped default; a value that
|
|
|
|
|
# differs from its own trim (padded or whitespace-only) is
|
|
|
|
|
# malformed and rejected.
|
2026-09-19 14:18:18 -04:00
|
|
|
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"
|
|
|
|
|
|
2026-09-23 05:12:27 -04:00
|
|
|
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
|
|
|
|
# submits the same way: a missing key keeps the current value. The
|
|
|
|
|
# base URL must be a full http(s) URL with a host — the same check
|
|
|
|
|
# the client's constructor enforces, so a malformed URL fails at
|
|
|
|
|
# the form, not at the first request. The token may be EMPTY (the
|
|
|
|
|
# API connection is simply not configured yet) but a non-empty one
|
|
|
|
|
# must be pasted unpadded: a padded or whitespace-only value is a
|
|
|
|
|
# clipboard mistake that would fail the token check at request
|
|
|
|
|
# time.
|
|
|
|
|
api_base_url = user_input.get(CONF_API_BASE_URL)
|
|
|
|
|
if api_base_url is not None and not is_valid_base_url(api_base_url):
|
|
|
|
|
errors[CONF_API_BASE_URL] = "invalid_url"
|
|
|
|
|
panel_token = user_input.get(CONF_PANEL_TOKEN)
|
|
|
|
|
if panel_token is not None and (
|
|
|
|
|
not isinstance(panel_token, str)
|
|
|
|
|
or panel_token != panel_token.strip()
|
|
|
|
|
):
|
|
|
|
|
errors[CONF_PANEL_TOKEN] = "invalid"
|
|
|
|
|
|
2026-09-13 18:26:09 -04:00
|
|
|
return errors
|
|
|
|
|
|
|
|
|
|
|
2026-09-19 14:18:18 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 17:03:53 -04:00
|
|
|
def _build_schema(
|
|
|
|
|
current: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
admin_choices: dict[str, str] | None = None,
|
|
|
|
|
admin_default: list[str] | None = None,
|
|
|
|
|
) -> vol.Schema:
|
|
|
|
|
"""Build the form schema, pre-filled with the current values.
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-19 14:18:18 -04:00
|
|
|
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.
|
2026-09-15 17:03:53 -04:00
|
|
|
"""
|
|
|
|
|
fields: dict[Any, Any] = {
|
|
|
|
|
vol.Required(
|
|
|
|
|
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
|
|
|
|
): int,
|
|
|
|
|
vol.Required(
|
|
|
|
|
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
|
|
|
|
): str,
|
|
|
|
|
vol.Required(
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
|
|
|
|
): int,
|
2026-09-19 11:41:12 -04:00
|
|
|
vol.Required(
|
|
|
|
|
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
|
|
|
|
): int,
|
2026-09-19 14:18:18 -04:00
|
|
|
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,
|
2026-09-23 05:12:27 -04:00
|
|
|
# Panel-plane API connection (Feature 18): the base URL the API
|
|
|
|
|
# client talks to and the panel service token it presents as
|
|
|
|
|
# the Bearer credential. Schema-validated submits always carry
|
|
|
|
|
# both (the defaults pre-fill from the stored values); raw
|
|
|
|
|
# callers omitting them keep their stored values (see
|
|
|
|
|
# _async_validate).
|
|
|
|
|
vol.Required(
|
|
|
|
|
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
|
|
|
|
): str,
|
|
|
|
|
vol.Required(
|
|
|
|
|
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
|
|
|
|
): str,
|
2026-09-15 17:03:53 -04:00
|
|
|
}
|
|
|
|
|
if admin_choices is not None:
|
2026-09-15 17:13:32 -04:00
|
|
|
# HA's supported multi-select validator: the frontend can
|
|
|
|
|
# serialize the field as a real picker of the given choices.
|
2026-09-15 17:03:53 -04:00
|
|
|
fields[
|
|
|
|
|
vol.Optional(
|
|
|
|
|
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
|
|
|
|
|
)
|
2026-09-15 17:13:32 -04:00
|
|
|
] = cv.multi_select(admin_choices)
|
2026-09-15 17:03:53 -04:00
|
|
|
return vol.Schema(fields)
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|
|
|
|
"""Handle the options flow for NestQuest."""
|
|
|
|
|
|
2026-09-15 17:03:53 -04:00
|
|
|
def _async_database(self):
|
|
|
|
|
"""Return the runtime database for this entry, or None.
|
|
|
|
|
|
|
|
|
|
Real HA only opens the options flow for an existing entry, so
|
|
|
|
|
the runtime record (and its database) exists; the None branch
|
|
|
|
|
covers direct construction in tests and degraded states.
|
|
|
|
|
"""
|
|
|
|
|
hass_data = getattr(self.hass, "data", None)
|
|
|
|
|
if not isinstance(hass_data, dict):
|
|
|
|
|
return None
|
|
|
|
|
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
|
|
|
|
database = getattr(record, "database", None)
|
|
|
|
|
return database if getattr(database, "connected", False) else None
|
|
|
|
|
|
|
|
|
|
async def _async_admin_choices(self) -> dict[str, str]:
|
|
|
|
|
"""Map HA user id -> display name for the picker.
|
|
|
|
|
|
|
|
|
|
Real Home Assistant always provides ``hass.auth``; a missing
|
|
|
|
|
one (direct construction in tests, degraded harness) degrades
|
|
|
|
|
to an empty picker rather than crashing the form.
|
|
|
|
|
"""
|
|
|
|
|
auth = getattr(self.hass, "auth", None)
|
|
|
|
|
if auth is None:
|
|
|
|
|
return {}
|
|
|
|
|
users = await auth.async_get_users()
|
|
|
|
|
return {
|
|
|
|
|
user.id: user.name
|
|
|
|
|
for user in users
|
|
|
|
|
if getattr(user, "id", None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def _async_current_admin_ids(self) -> list[str]:
|
|
|
|
|
"""Return the current allowlist, or [] without a database."""
|
|
|
|
|
database = self._async_database()
|
|
|
|
|
if database is None:
|
|
|
|
|
return []
|
|
|
|
|
return await list_admin_ids(database)
|
|
|
|
|
|
2026-09-13 18:26:09 -04:00
|
|
|
async def async_step_init(
|
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
|
) -> FlowResult:
|
|
|
|
|
"""Manage the NestQuest options."""
|
2026-09-15 17:03:53 -04:00
|
|
|
admin_choices = await self._async_admin_choices()
|
|
|
|
|
admin_default = await self._async_current_admin_ids()
|
2026-09-13 18:26:09 -04:00
|
|
|
if user_input is None:
|
|
|
|
|
return self.async_show_form(
|
2026-09-15 17:03:53 -04:00
|
|
|
step_id="init",
|
|
|
|
|
data_schema=_build_schema(
|
|
|
|
|
self._current_values(),
|
|
|
|
|
admin_choices=admin_choices,
|
|
|
|
|
admin_default=admin_default,
|
|
|
|
|
),
|
2026-09-13 18:26:09 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
errors = _async_validate(user_input)
|
2026-09-15 17:03:53 -04:00
|
|
|
# The admin ids are validated only when the key is present:
|
|
|
|
|
# schema-validated submits always carry it (vol.Optional's
|
|
|
|
|
# default fills from the current allowlist), while raw callers
|
|
|
|
|
# omitting the key keep the field untouched.
|
|
|
|
|
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
|
|
|
|
|
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
|
2026-09-13 18:26:09 -04:00
|
|
|
if errors:
|
|
|
|
|
return self.async_show_form(
|
|
|
|
|
step_id="init",
|
2026-09-15 17:03:53 -04:00
|
|
|
data_schema=_build_schema(
|
|
|
|
|
self._current_values(),
|
|
|
|
|
admin_choices=admin_choices,
|
|
|
|
|
admin_default=admin_default,
|
|
|
|
|
),
|
|
|
|
|
errors=errors,
|
|
|
|
|
)
|
|
|
|
|
if admin_ids_present and (
|
|
|
|
|
not isinstance(admin_ids, list)
|
|
|
|
|
or any(not isinstance(value, str) for value in admin_ids)
|
|
|
|
|
):
|
|
|
|
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
|
|
|
|
elif admin_ids_present and not admin_ids:
|
|
|
|
|
# An empty allowlist fails closed: saving zero admins is
|
|
|
|
|
# refused, never silently applied.
|
|
|
|
|
errors[CONF_ADMIN_USER_IDS] = "no_admins"
|
|
|
|
|
elif admin_ids_present and any(
|
|
|
|
|
value not in admin_choices for value in admin_ids
|
|
|
|
|
):
|
|
|
|
|
# The schema enforces membership for real HA submits; this
|
|
|
|
|
# backs the raw-input path up so an unknown id can never
|
|
|
|
|
# reach the allowlist.
|
|
|
|
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
2026-09-15 17:16:10 -04:00
|
|
|
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
|
|
|
|
|
# set_admin_ids refuses duplicates with ValueError; a flow
|
|
|
|
|
# submission must surface that as a field error instead of
|
|
|
|
|
# an unhandled crash.
|
|
|
|
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
2026-09-15 17:03:53 -04:00
|
|
|
if errors:
|
|
|
|
|
return self.async_show_form(
|
|
|
|
|
step_id="init",
|
|
|
|
|
data_schema=_build_schema(
|
|
|
|
|
self._current_values(),
|
|
|
|
|
admin_choices=admin_choices,
|
|
|
|
|
admin_default=admin_default,
|
|
|
|
|
),
|
2026-09-13 18:26:09 -04:00
|
|
|
errors=errors,
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-15 17:03:53 -04:00
|
|
|
# Apply the picker's selection to the allowlist when the
|
|
|
|
|
# runtime database is reachable; the selection is always
|
|
|
|
|
# stored on the entry as the disaster-recovery copy.
|
|
|
|
|
database = self._async_database()
|
|
|
|
|
if database is not None and admin_ids_present:
|
|
|
|
|
await set_admin_ids(database, admin_ids)
|
|
|
|
|
data = {
|
|
|
|
|
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
|
|
|
|
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
2026-09-19 11:41:12 -04:00
|
|
|
CONF_UPDATE_INTERVAL: user_input.get(
|
|
|
|
|
CONF_UPDATE_INTERVAL,
|
|
|
|
|
self._current_values()[CONF_UPDATE_INTERVAL],
|
|
|
|
|
),
|
2026-09-15 17:03:53 -04:00
|
|
|
}
|
2026-09-19 14:18:18 -04:00
|
|
|
# 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]
|
|
|
|
|
)
|
2026-09-23 05:12:27 -04:00
|
|
|
# The API connection section (Feature 18) stores the base URL
|
|
|
|
|
# and panel token the same fall-back-to-current way, so raw
|
|
|
|
|
# submits that predate the section keep their stored values.
|
|
|
|
|
for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
|
|
|
|
data[api_key] = user_input.get(api_key, current[api_key])
|
2026-09-15 17:03:53 -04:00
|
|
|
if admin_ids_present:
|
|
|
|
|
data[CONF_ADMIN_USER_IDS] = admin_ids
|
2026-09-15 17:30:29 -04:00
|
|
|
elif admin_default:
|
|
|
|
|
# HA replaces entry.options wholesale, so an omitted picker
|
|
|
|
|
# field must carry the CURRENT allowlist forward: dropping
|
|
|
|
|
# it would leave only the stale config-flow copy in entry
|
|
|
|
|
# data, and a later database loss would resurrect admins
|
|
|
|
|
# the owner has since removed.
|
|
|
|
|
data[CONF_ADMIN_USER_IDS] = list(admin_default)
|
2026-09-15 17:03:53 -04:00
|
|
|
return self.async_create_entry(title="NestQuest", data=data)
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
def _current_values(self) -> dict[str, Any]:
|
|
|
|
|
"""Resolve current values from entry options, then entry data, then defaults."""
|
|
|
|
|
options = self.options
|
|
|
|
|
entry_data = getattr(self.config_entry, "data", None) or {}
|
|
|
|
|
values: dict[str, Any] = {}
|
|
|
|
|
for key, default in (
|
|
|
|
|
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
|
|
|
|
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
|
|
|
|
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
2026-09-19 11:41:12 -04:00
|
|
|
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
2026-09-19 14:18:18 -04:00
|
|
|
(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),
|
2026-09-23 05:12:27 -04:00
|
|
|
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
|
|
|
|
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
2026-09-13 18:26:09 -04:00
|
|
|
):
|
|
|
|
|
if key in options:
|
|
|
|
|
values[key] = options[key]
|
|
|
|
|
elif key in entry_data:
|
|
|
|
|
values[key] = entry_data[key]
|
|
|
|
|
else:
|
|
|
|
|
values[key] = default
|
|
|
|
|
return values
|