"""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, 0–23 hours, 0–59 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": "🔧 测试通知 — 您的通知设置已生效!", }, "pt-br": { "success": "✅ Notificação de teste enviada — seu serviço funciona. Se um dispositivo específico não receber nada, verifique esse dispositivo dentro do seu grupo de notificação (e os logs do Home Assistant).", "no_service": "⚠️ Nenhum serviço de notificação configurado. Configure um serviço primeiro em Configurações Gerais.", "invalid_service": "❌ O formato do serviço de notificação é inválido. 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 — sua configuração de notificações está funcionando!", }, "hu": { "success": "✅ Tesztértesítés elküldve — a szolgáltatás működik. Ha egy adott eszközre nem érkezik semmi, ellenőrizze az eszközt az értesítési csoportban (és a Home Assistant naplóit).", "no_service": "⚠️ Nincs értesítési szolgáltatás beállítva. Először állítson be egyet az Általános beállításokban.", "invalid_service": "❌ Az értesítési szolgáltatás formátuma érvénytelen. Használja a 'notify.szolgaltatas_nev' formát.", "failed": "❌ A tesztértesítés küldése nem sikerült. Ellenőrizze a szolgáltatás beállításait.", "push_message": "🔧 Tesztértesítés — az értesítési rendszere működik!", }, "ko": { "success": "✅ 테스트 알림을 보냈습니다 — 서비스가 작동합니다. 특정 기기에 알림이 오지 않으면 알림 그룹 내 해당 기기와 Home Assistant 로그를 확인하세요.", "no_service": "⚠️ 알림 서비스가 설정되지 않았습니다. 먼저 일반 설정에서 서비스를 설정하세요.", "invalid_service": "❌ 알림 서비스 형식이 잘못되었습니다. 'notify.service_name' 형식을 사용하세요.", "failed": "❌ 테스트 알림 전송에 실패했습니다. 서비스 설정을 확인하세요.", "push_message": "🔧 테스트 알림 — 알림 설정이 정상 작동합니다!", }, "tr": { "success": "✅ Test bildirimi gönderildi — servisiniz çalışıyor. Belirli bir cihaza bildirim gelmiyorsa bildirim grubunuzdaki o cihazı (ve Home Assistant günlüklerini) kontrol edin.", "no_service": "⚠️ Yapılandırılmış bildirim servisi yok. Önce Genel Ayarlar'da bir servis yapılandırın.", "invalid_service": "❌ Bildirim servisi biçimi geçersiz. 'notify.servis_adi' kullanın.", "failed": "❌ Test bildirimi gönderilemedi. Servis yapılandırmanızı kontrol edin.", "push_message": "🔧 Test bildirimi — bildirim kurulumunuz çalışıyor!", }, } 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(), )