New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,835 @@
"""Global options flow for the Maintenance Supporter integration.
Contains GlobalOptionsFlow: menu-based global settings (notifications, budget, groups).
Split from config_flow_options.py for better maintainability.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from uuid import uuid4
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector
from .const import (
BUDGET_CURRENCIES,
CONF_ACTION_COMPLETE_ENABLED,
CONF_ACTION_SKIP_ENABLED,
CONF_ACTION_SNOOZE_ENABLED,
CONF_ADMIN_PANEL_USER_IDS,
CONF_ADVANCED_ADAPTIVE,
CONF_ADVANCED_BUDGET,
CONF_ADVANCED_CHECKLISTS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_DEFAULT_WARNING_DAYS,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
CONF_OPERATOR_WRITE_ENABLED,
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_SNOOZE_DURATION_HOURS,
DEFAULT_BUDGET_CURRENCY,
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
DEFAULT_PANEL_ENABLED,
DEFAULT_SNOOZE_DURATION_HOURS,
DEFAULT_WARNING_DAYS,
MAX_PANEL_TITLE_LENGTH,
TIME_HHMMSS_PATTERN,
)
from .helpers.i18n import normalize_language
from .helpers.notify_targets import build_notify_targets
from .helpers.settings_registry import float_range, int_range
_LOGGER = logging.getLogger(__name__)
# NumberSelector bounds are pulled from the shared settings registry so the
# options-flow forms can't drift from the WS write-handler's range validation.
_WARN_MIN, _WARN_MAX = int_range(CONF_DEFAULT_WARNING_DAYS)
# The three per-status notify-interval selectors share one bound. That's only
# valid while the registry keeps their ranges identical — assert it so a future
# divergence fails loudly at load instead of silently using the due-soon bound.
_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX = int_range(CONF_NOTIFY_DUE_SOON_INTERVAL)
assert (
int_range(CONF_NOTIFY_OVERDUE_INTERVAL)
== int_range(CONF_NOTIFY_TRIGGERED_INTERVAL)
== (_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX)
), "notify-interval ranges diverged — give each selector its own registry bound"
_MAX_PER_DAY_MIN, _MAX_PER_DAY_MAX = int_range(CONF_MAX_NOTIFICATIONS_PER_DAY)
_BUNDLE_MIN, _BUNDLE_MAX = int_range(CONF_NOTIFICATION_BUNDLE_THRESHOLD)
_SNOOZE_MIN, _SNOOZE_MAX = int_range(CONF_SNOOZE_DURATION_HOURS)
_ALERT_MIN, _ALERT_MAX = int_range(CONF_BUDGET_ALERT_THRESHOLD)
_BUDGET_MONTHLY_MIN, _BUDGET_MONTHLY_MAX = float_range(CONF_BUDGET_MONTHLY)
_BUDGET_YEARLY_MIN, _BUDGET_YEARLY_MAX = float_range(CONF_BUDGET_YEARLY)
_VALID_SERVICE_PART = re.compile(r"^[a-z0-9_]+$")
# v1.4.6: HH:MM or HH:MM:SS, 023 hours, 059 minutes/seconds. Shared with the
# WS handler via const.TIME_HHMMSS_PATTERN so the two can't diverge.
_VALID_TIME_PATTERN = TIME_HHMMSS_PATTERN
def _safe_time(value: Any, fallback: str) -> str:
"""Return ``value`` if it parses as HH:MM[:SS], else the ``fallback``.
HA's `TimeSelector` rejects empty strings, `None`, and non-time strings as
"Invalid time" — and that error then blocks the entire form save, even when
the user wasn't editing the time field. Coerce the form's *default* to a
valid time so the user can still hit Save.
"""
if isinstance(value, str) and _VALID_TIME_PATTERN.match(value):
return value
return fallback
def validate_notify_service(raw: str, hass: HomeAssistant | None = None) -> tuple[str, str | None]:
"""Normalize and validate a notify service string.
Returns (normalized_value, error_key | None).
"""
value = raw.strip()
if not value:
return ("", None)
# Auto-fix: prepend "notify." if missing
if "." not in value:
value = f"notify.{value}"
parts = value.split(".")
if len(parts) != 2 or parts[0] != "notify" or not parts[1] or not _VALID_SERVICE_PART.match(parts[1]):
return (value, "invalid_notify_service")
# Check service existence (only when hass available, i.e. options flow)
if hass is not None and not hass.services.has_service(parts[0], parts[1]):
return (value, "notify_service_not_found")
return (value, None)
_TEST_NOTIFICATION_RESULTS: dict[str, dict[str, str]] = {
"de": {
"success": "✅ Testbenachrichtigung gesendet — Ihr Dienst funktioniert. Wenn ein bestimmtes Gerät nichts bekommt, prüfen Sie dieses Gerät in Ihrer Notify-Gruppe (und das HA-Protokoll).",
"no_service": "⚠️ Kein Benachrichtigungsdienst konfiguriert. Bitte zuerst unter Allgemeine Einstellungen einen Dienst einrichten.",
"invalid_service": "❌ Das Format des Benachrichtigungsdienstes ist ungültig. Verwenden Sie 'notify.dienstname'.",
"failed": "❌ Testbenachrichtigung konnte nicht gesendet werden. Bitte prüfen Sie Ihre Konfiguration.",
"push_message": "🔧 Testbenachrichtigung — Ihre Benachrichtigungseinrichtung funktioniert!",
},
"nl": {
"success": "✅ Testmelding verzonden — uw service werkt. Krijgt een specifiek apparaat niets, controleer dat apparaat in uw notify-groep (en het HA-logboek).",
"no_service": "⚠️ Geen meldingsservice geconfigureerd. Stel eerst een service in onder Algemene instellingen.",
"invalid_service": "❌ Het formaat van de meldingsservice is ongeldig. Gebruik 'notify.servicenaam'.",
"failed": "❌ Testmelding kon niet worden verzonden. Controleer uw configuratie.",
"push_message": "🔧 Testmelding — uw meldingsinstellingen werken!",
},
"fr": {
"success": "✅ Notification de test envoyée — votre service fonctionne. Si un appareil précis ne reçoit rien, vérifiez-le dans votre groupe notify (et les journaux HA).",
"no_service": "⚠️ Aucun service de notification configuré. Veuillez d'abord configurer un service dans les paramètres généraux.",
"invalid_service": "❌ Le format du service de notification est invalide. Utilisez 'notify.nom_du_service'.",
"failed": "❌ Impossible d'envoyer la notification de test. Veuillez vérifier votre configuration.",
"push_message": "🔧 Notification de test — votre configuration de notifications fonctionne !",
},
"it": {
"success": "✅ Notifica di test inviata — il servizio funziona. Se un dispositivo specifico non riceve nulla, controllalo nel tuo gruppo notify (e nei log di HA).",
"no_service": "⚠️ Nessun servizio di notifica configurato. Configura prima un servizio nelle impostazioni generali.",
"invalid_service": "❌ Il formato del servizio di notifica non è valido. Usa 'notify.nome_servizio'.",
"failed": "❌ Impossibile inviare la notifica di test. Verifica la tua configurazione.",
"push_message": "🔧 Notifica di test — la configurazione delle notifiche funziona!",
},
"es": {
"success": "✅ Notificación de prueba enviada — tu servicio funciona. Si un dispositivo concreto no recibe nada, revísalo en tu grupo notify (y los registros de HA).",
"no_service": "⚠️ No hay servicio de notificación configurado. Configure primero un servicio en la configuración general.",
"invalid_service": "❌ El formato del servicio de notificación no es válido. Use 'notify.nombre_servicio'.",
"failed": "❌ No se pudo enviar la notificación de prueba. Verifique su configuración.",
"push_message": "🔧 Notificación de prueba — ¡su configuración de notificaciones funciona!",
},
"en": {
"success": "✅ Test notification sent — your service works. If a specific device gets nothing, check that device inside your notify group (and Home Assistant's logs).",
"no_service": "⚠️ No notification service configured. Please configure a service in General Settings first.",
"invalid_service": "❌ The notification service format is invalid. Use 'notify.service_name'.",
"failed": "❌ Failed to send the test notification. Please verify your service configuration.",
"push_message": "🔧 Test notification — your notification setup is working!",
},
"ru": {
"success": "✅ Тестовое уведомление отправлено — сервис работает. Если конкретное устройство ничего не получает, проверьте его в вашей notify-группе (и в журналах HA).",
"no_service": "⚠️ Сервис уведомлений не настроен. Сначала настройте сервис в Основных настройках.",
"invalid_service": "❌ Неверный формат сервиса уведомлений. Используйте 'notify.имя_сервиса'.",
"failed": "❌ Не удалось отправить тестовое уведомление. Проверьте настройки сервиса.",
"push_message": "🔧 Тестовое уведомление — ваша система уведомлений работает!",
},
"uk": {
"success": "✅ Тестове сповіщення надіслано — служба працює. Якщо певний пристрій нічого не отримує, перевірте його у вашій notify-групі (та в журналах HA).",
"no_service": "⚠️ Службу сповіщень не налаштовано. Спочатку вкажіть службу в загальних налаштуваннях.",
"invalid_service": "❌ Невірний формат служби сповіщень. Використовуйте 'notify.service_name'.",
"failed": "❌ Не вдалося надіслати тестове сповіщення. Перевірте конфігурацію служби.",
"push_message": "🔧 Тестове сповіщення — ваші сповіщення працюють!",
},
"pt": {
"success": "✅ Notificação de teste enviada — o seu serviço funciona. Se um dispositivo específico não receber nada, verifique-o no seu grupo notify (e nos registos do HA).",
"no_service": "⚠️ Serviço de notificação não configurado. Configure primeiro nas Configurações Gerais.",
"invalid_service": "❌ Formato inválido do serviço de notificação. Use 'notify.nome_do_servico'.",
"failed": "❌ Falha ao enviar a notificação de teste. Verifique a configuração do serviço.",
"push_message": "🔧 Notificação de teste — as suas notificações estão a funcionar!",
},
"zh": {
"success": "✅ 测试通知已发送 — 您的服务正常。如果某个设备未收到,请在您的 notify 群组中检查该设备(以及 HA 日志)。",
"no_service": "⚠️ 未配置通知服务。请先在“通用设置”中配置服务。",
"invalid_service": "❌ 通知服务格式无效。请使用 'notify.服务名称' 格式。",
"failed": "❌ 测试通知发送失败。请验证您的服务配置。",
"push_message": "🔧 测试通知 — 您的通知设置已生效!",
},
}
def _get_test_result_text(hass: HomeAssistant, key: str) -> str:
"""Get localized test notification result text."""
lang = normalize_language(hass)
texts = _TEST_NOTIFICATION_RESULTS.get(lang, _TEST_NOTIFICATION_RESULTS["en"])
return texts.get(key, texts.get("failed", key))
async def send_test_notification(hass: HomeAssistant, options: dict[str, Any]) -> str:
"""Send a test notification using the configured notify service.
Returns a result key ("success", "no_service", "invalid_service", "failed")
that callers map to localized text. Action buttons are included whenever
the corresponding action-feature toggles are enabled, so the rendered
notification matches the real layout users see for actual tasks.
"""
notify_service = str(options.get(CONF_NOTIFY_SERVICE, ""))
if not notify_service:
return "no_service"
# Format-only validation — existence is left to the async_call below so
# notify services registered lazily (e.g. mobile_app_*) still test cleanly.
normalized, error = validate_notify_service(notify_service)
if error:
return "invalid_service"
try:
from .helpers.notification_manager import async_dispatch_notify
push_msg = _get_test_result_text(hass, "push_message")
service_data: dict[str, Any] = {
"title": "Maintenance Supporter",
"message": push_msg,
}
actions_enabled = options.get(CONF_ACTION_COMPLETE_ENABLED, False)
skip_enabled = options.get(CONF_ACTION_SKIP_ENABLED, False)
snooze_enabled = options.get(CONF_ACTION_SNOOZE_ENABLED, False)
if actions_enabled or skip_enabled or snooze_enabled:
test_actions: list[dict[str, str]] = []
if actions_enabled:
test_actions.append({"action": "MS_TEST_COMPLETE", "title": "\u2705 Complete"})
if skip_enabled:
test_actions.append({"action": "MS_TEST_SKIP", "title": "\u23ed\ufe0f Skip"})
if snooze_enabled:
test_actions.append({"action": "MS_TEST_SNOOZE", "title": "\U0001f4a4 Snooze"})
service_data["data"] = {"actions": test_actions}
# Dual-path: legacy notify service OR notify entity (send_message).
if not await async_dispatch_notify(hass, normalized, service_data, blocking=True):
return "failed"
return "success"
except Exception: # noqa: BLE001 - any failure mode reports "failed" to the UI
_LOGGER.debug("Test notification failed for %s", notify_service, exc_info=True)
return "failed"
class GlobalOptionsFlow(OptionsFlow):
"""Handle global options with menu-based navigation."""
@property
def _current(self) -> dict[str, Any]:
"""Get current options."""
return dict(self.config_entry.options or self.config_entry.data)
def _save_and_return(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Merge user input into options and return to the menu."""
merged = self._current
merged.update(user_input)
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
def _menu_options(self) -> list[str]:
"""Build dynamic menu options."""
current = self._current
options = ["general_settings", "advanced_features", "panel_access"]
if current.get(CONF_ADVANCED_BUDGET, False):
options.append("budget_settings")
if current.get(CONF_ADVANCED_GROUPS, False):
options.append("manage_groups")
if current.get(CONF_NOTIFICATIONS_ENABLED, False):
options.extend(
[
"notification_settings",
"notification_actions",
"test_notification",
]
)
options.append("done")
return options
# --- Menu ---
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Show global options menu."""
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
async def async_step_global_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle menu selection redirect."""
return await self.async_step_init()
# Keep old step name as redirect for HA compatibility
async def async_step_global_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Redirect old step name."""
return await self.async_step_init()
async def async_step_done(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Finish and close the options flow."""
return self.async_create_entry(title="", data=self._current)
# --- Advanced Features ---
async def async_step_advanced_features(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Toggle visibility of advanced feature sections."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="advanced_features",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ADVANCED_ADAPTIVE,
default=current.get(CONF_ADVANCED_ADAPTIVE, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_PREDICTIONS,
default=current.get(CONF_ADVANCED_PREDICTIONS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SEASONAL,
default=current.get(CONF_ADVANCED_SEASONAL, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_ENVIRONMENTAL,
default=current.get(CONF_ADVANCED_ENVIRONMENTAL, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_BUDGET,
default=current.get(CONF_ADVANCED_BUDGET, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_GROUPS,
default=current.get(CONF_ADVANCED_GROUPS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_CHECKLISTS,
default=current.get(CONF_ADVANCED_CHECKLISTS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SCHEDULE_TIME,
default=current.get(CONF_ADVANCED_SCHEDULE_TIME, False),
): selector.BooleanSelector(),
}
),
)
# --- Panel Access (per-user override) ---
async def async_step_panel_access(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Operator write delegation + the non-admin allowlist.
Admins always have full write access. The ``operator_write_enabled``
switch is OFF by default, so listed non-admins get only the read-only
operator view (Complete / Skip). Turn it on to grant the listed users
full create / edit / delete. Both controls are admin-only.
"""
if user_input is not None:
return self._save_and_return(user_input)
# Build the multi-select option list from the HA auth registry,
# mirroring the panel's own users/list filter (active humans only).
users = await self.hass.auth.async_get_users()
non_admin = [u for u in users if not u.is_admin and not u.system_generated and u.is_active]
options = [
selector.SelectOptionDict(
value=u.id,
label=(u.name or u.id[:8]),
)
for u in non_admin
]
current = self._current
return self.async_show_form(
step_id="panel_access",
data_schema=vol.Schema(
{
vol.Optional(
CONF_OPERATOR_WRITE_ENABLED,
default=current.get(CONF_OPERATOR_WRITE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADMIN_PANEL_USER_IDS,
default=current.get(CONF_ADMIN_PANEL_USER_IDS, []),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
),
),
}
),
description_placeholders={
"user_count": str(len(non_admin)),
},
)
# --- General Settings ---
async def async_step_general_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""General settings: warning days, notifications toggle, service, panel."""
errors: dict[str, str] = {}
if user_input is not None:
raw_service = user_input.get(CONF_NOTIFY_SERVICE, "")
# Format-only validation — NO existence check. A notify service can be
# lazily registered or momentarily absent (e.g. mobile_app_*), so we no
# longer hard-block saving it (that broke #77). A genuinely-missing
# service is surfaced afterwards by the runtime repair issue
# (notify_service_missing) instead.
normalized, error = validate_notify_service(raw_service)
if error:
errors[CONF_NOTIFY_SERVICE] = error
else:
user_input[CONF_NOTIFY_SERVICE] = normalized
# Trim + cap the optional sidebar title; blank clears the override
# (panel falls back to the default "Maintenance").
raw_title = user_input.get(CONF_PANEL_TITLE)
if isinstance(raw_title, str):
user_input[CONF_PANEL_TITLE] = raw_title.strip()[:MAX_PANEL_TITLE_LENGTH]
if not errors:
return self._save_and_return(user_input)
current = self._current
currency_code = current.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY)
currency_options = [
selector.SelectOptionDict(value=code, label=f"{code} ({symbol})") for code, symbol in BUDGET_CURRENCIES.items()
]
# Offer every notify target as a dropdown so users don't have to guess
# the slug. The merge (legacy notify services + notify entities, minus
# the generic send_message, plus the current saved value) is shared with
# the panel via build_notify_targets so the two surfaces can't drift.
# ``custom_value`` keeps free text working for not-yet-loaded targets.
notify_services = build_notify_targets(self.hass, current=current.get(CONF_NOTIFY_SERVICE, ""))
return self.async_show_form(
step_id="general_settings",
data_schema=vol.Schema(
{
vol.Optional(
CONF_DEFAULT_WARNING_DAYS,
default=current.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
): selector.NumberSelector(
selector.NumberSelectorConfig(min=_WARN_MIN, max=_WARN_MAX, step=1, mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(
CONF_BUDGET_CURRENCY,
default=currency_code,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=currency_options,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(
CONF_NOTIFICATIONS_ENABLED,
default=current.get(CONF_NOTIFICATIONS_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_SERVICE,
default=current.get(CONF_NOTIFY_SERVICE, ""),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=notify_services,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
vol.Optional(
CONF_PANEL_ENABLED,
default=current.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED),
): selector.BooleanSelector(),
# Blank clears the override → panel falls back to the default
# title ("Maintenance"). suggested_value pre-fills the current
# custom value, or empty when none is set.
vol.Optional(
CONF_PANEL_TITLE,
description={"suggested_value": current.get(CONF_PANEL_TITLE, "")},
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
}
),
errors=errors,
)
# --- Notification Settings ---
async def async_step_notification_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Per-status notification toggles, intervals, quiet hours, daily limit."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="notification_settings",
data_schema=vol.Schema(
{
# --- Due Soon ---
vol.Optional(
CONF_NOTIFY_DUE_SOON_ENABLED,
default=current.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_DUE_SOON_INTERVAL,
default=current.get(CONF_NOTIFY_DUE_SOON_INTERVAL, 24),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Overdue ---
vol.Optional(
CONF_NOTIFY_OVERDUE_ENABLED,
default=current.get(CONF_NOTIFY_OVERDUE_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_OVERDUE_INTERVAL,
default=current.get(CONF_NOTIFY_OVERDUE_INTERVAL, 12),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Triggered ---
vol.Optional(
CONF_NOTIFY_TRIGGERED_ENABLED,
default=current.get(CONF_NOTIFY_TRIGGERED_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_TRIGGERED_INTERVAL,
default=current.get(CONF_NOTIFY_TRIGGERED_INTERVAL, 0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Quiet Hours ---
vol.Optional(
CONF_QUIET_HOURS_ENABLED,
default=current.get(CONF_QUIET_HOURS_ENABLED, True),
): selector.BooleanSelector(),
# v1.4.6 (#44 follow-up): use `or` instead of dict-default so
# empty-string / null / non-HH:MM values in storage don't
# break the whole form. HA's TimeSelector rejects an empty
# string as "Invalid time" and that error blocks the save
# button — even if the user is here to change something
# else and quiet_hours is disabled. Coerce to the sane
# fallback whenever the persisted value isn't a usable time.
vol.Optional(
CONF_QUIET_HOURS_START,
default=_safe_time(current.get(CONF_QUIET_HOURS_START), "22:00"),
): selector.TimeSelector(),
vol.Optional(
CONF_QUIET_HOURS_END,
default=_safe_time(current.get(CONF_QUIET_HOURS_END), "08:00"),
): selector.TimeSelector(),
# --- Daily Limit ---
vol.Optional(
CONF_MAX_NOTIFICATIONS_PER_DAY,
default=current.get(CONF_MAX_NOTIFICATIONS_PER_DAY, DEFAULT_MAX_NOTIFICATIONS_PER_DAY),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_MAX_PER_DAY_MIN, max=_MAX_PER_DAY_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Bundling ---
vol.Optional(
CONF_NOTIFICATION_BUNDLING_ENABLED,
default=current.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
default=current.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUNDLE_MIN, max=_BUNDLE_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# v1.4.0 (#44): notification title style
vol.Optional(
CONF_NOTIFICATION_TITLE_STYLE,
default=current.get(CONF_NOTIFICATION_TITLE_STYLE, "default"),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["default", "object_name", "task_name"],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="notification_title_style",
)
),
}
),
)
# --- Notification Actions ---
async def async_step_notification_actions(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Interactive action buttons for mobile notifications."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="notification_actions",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ACTION_COMPLETE_ENABLED,
default=current.get(CONF_ACTION_COMPLETE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SKIP_ENABLED,
default=current.get(CONF_ACTION_SKIP_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SNOOZE_ENABLED,
default=current.get(CONF_ACTION_SNOOZE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_SNOOZE_DURATION_HOURS,
default=current.get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_SNOOZE_MIN, max=_SNOOZE_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
),
)
# --- Test Notification ---
async def async_step_test_notification(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Send a test notification and show the result."""
if user_input is not None:
# User acknowledged the result — return to menu
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
# First call: send the test notification via shared helper so the
# same actions appear here as from the panel WS call.
result_key = await send_test_notification(self.hass, self._current)
result_text = _get_test_result_text(self.hass, result_key)
return self.async_show_form(
step_id="test_notification",
data_schema=vol.Schema({}),
description_placeholders={"result": result_text},
)
# --- Budget Settings ---
async def async_step_budget_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Budget settings: monthly/yearly budget, alerts."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
currency_code = current.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY)
currency_symbol = BUDGET_CURRENCIES.get(currency_code, "")
return self.async_show_form(
step_id="budget_settings",
data_schema=vol.Schema(
{
vol.Optional(
CONF_BUDGET_MONTHLY,
default=current.get(CONF_BUDGET_MONTHLY, 0.0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUDGET_MONTHLY_MIN,
max=_BUDGET_MONTHLY_MAX,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement=currency_symbol,
)
),
vol.Optional(
CONF_BUDGET_YEARLY,
default=current.get(CONF_BUDGET_YEARLY, 0.0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUDGET_YEARLY_MIN,
max=_BUDGET_YEARLY_MAX,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement=currency_symbol,
)
),
vol.Optional(
CONF_BUDGET_ALERTS_ENABLED,
default=current.get(CONF_BUDGET_ALERTS_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_BUDGET_ALERT_THRESHOLD,
default=current.get(CONF_BUDGET_ALERT_THRESHOLD, 80),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_ALERT_MIN,
max=_ALERT_MAX,
step=5,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="%",
)
),
}
),
)
# --- Manage Groups ---
async def async_step_manage_groups(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""List and manage maintenance groups."""
from .const import CONF_GROUPS
current = self._current
groups = current.get(CONF_GROUPS, {})
if user_input is not None:
selected = user_input.get("selected_group")
if selected == "_add_new":
return await self.async_step_add_group()
if selected and selected in groups:
return await self._delete_group(selected)
if not groups:
# No groups yet — go directly to add
return await self.async_step_add_group()
options = [
selector.SelectOptionDict(
value=gid,
label=f"{gdata.get('name', gid)} ({len(gdata.get('task_refs', []))} tasks)",
)
for gid, gdata in groups.items()
]
options.append(selector.SelectOptionDict(value="_add_new", label="+ Add New Group"))
return self.async_show_form(
step_id="manage_groups",
data_schema=vol.Schema(
{
vol.Required("selected_group"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
mode=selector.SelectSelectorMode.LIST,
)
),
}
),
)
async def async_step_add_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Add a new maintenance group."""
from .const import CONF_GROUPS
from .helpers.sanitize import cap_group_fields
if user_input is not None:
group_name = user_input.get("group_name", "").strip()
if group_name:
group_id = uuid4().hex
merged = self._current
groups = dict(merged.get(CONF_GROUPS, {}))
new_group = {
"name": group_name,
"description": user_input.get("group_description", ""),
"task_refs": [],
}
cap_group_fields(new_group)
groups[group_id] = new_group
merged[CONF_GROUPS] = groups
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
return self.async_show_form(
step_id="add_group",
data_schema=vol.Schema(
{
vol.Required("group_name"): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("group_description", default=""): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
multiline=True,
)
),
}
),
)
async def _delete_group(self, group_id: str) -> ConfigFlowResult:
"""Delete a group and return to menu."""
from .const import CONF_GROUPS
merged = self._current
groups = dict(merged.get(CONF_GROUPS, {}))
groups.pop(group_id, None)
merged[CONF_GROUPS] = groups
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)