Files
HomeAssistantVS/custom_components/maintenance_supporter/config_flow_options_global.py
T

1680 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 .config_flow_helpers import select_default
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_COMPLETION_ACTIONS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BATTERY_AUTO_RECORD_RECOVERY,
CONF_BATTERY_LIFETIME_MONTHS,
CONF_BATTERY_LOW_PERCENT,
CONF_BATTERY_RECOVERED_PERCENT,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_CURRENCY_DECIMALS,
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_HOME_REGION,
CONF_HOME_TYPE,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_COMPLETED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_EVENT_ONLY,
CONF_NOTIFY_EXTRA_DATA,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SCOPE_VIEW_ID,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
CONF_OBJECTS_TABLE_COLUMNS,
CONF_OPERATOR_WRITE_ENABLED,
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_PART_SEARCH_URL_TEMPLATE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_REF_NUMBERS_IN_LISTS,
CONF_REMINDER_LEAD_DAYS,
CONF_ROW_ACTION_STYLE,
CONF_SHOPPING_LIST_ENTITY,
CONF_SNOOZE_DURATION_HOURS,
CONF_WARRANTY_REMINDER_DAYS,
CONF_WARRANTY_REMINDER_ENABLED,
CONF_WEEKLY_DIGEST_ENABLED,
DEFAULT_BUDGET_CURRENCY,
HOME_REGION_AUTO,
HOME_TYPES,
KNOWN_OBJECT_TABLE_COLUMNS,
MAX_NOTIFY_EXTRA_DATA_LENGTH,
MAX_PANEL_TITLE_LENGTH,
MAX_REMINDER_LEADS,
NOTIFY_COMPLETED_MODES,
ROW_ACTION_STYLES,
TIME_HHMMSS_PATTERN,
)
from .helpers.dates import normalize_hhmm
from .helpers.i18n import normalize_language
from .helpers.notify_targets import build_notify_targets
from .helpers.settings_registry import float_range, int_range, setting_default
_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_]+$")
# The notification scope's "every task" choice (a select option can't be "").
_SCOPE_ALL = "__all__"
# 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!",
"user_no_device": "ℹ️ Diesem Nutzer ist kein Companion-Gerät zugeordnet — seine Erinnerungen gehen an den Haushalts-Dienst.",
},
"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!",
"user_no_device": "ℹ️ Aan deze gebruiker is geen Companion-apparaat gekoppeld — hun herinneringen gaan naar de huishoudelijke dienst.",
},
"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 !",
"user_no_device": "ℹ️ Aucun appareil Companion n'est lié à cet utilisateur — ses rappels partent vers le service du foyer.",
},
"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!",
"user_no_device": "ℹ️ Nessun dispositivo Companion è collegato a questo utente — i suoi promemoria vanno al servizio della casa.",
},
"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!",
"user_no_device": "ℹ️ Este usuario no tiene ningún dispositivo Companion vinculado — sus recordatorios van al servicio del hogar.",
},
"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!",
"user_no_device": "ℹ️ No Companion device is linked to this user — their reminders go to the household service.",
},
"ru": {
"success": "✅ Тестовое уведомление отправлено — сервис работает. Если конкретное устройство ничего не получает, проверьте его в вашей notify-группе (и в журналах HA).",
"no_service": "⚠️ Сервис уведомлений не настроен. Сначала настройте сервис в Основных настройках.",
"invalid_service": "❌ Неверный формат сервиса уведомлений. Используйте 'notify.имя_сервиса'.",
"failed": "❌ Не удалось отправить тестовое уведомление. Проверьте настройки сервиса.",
"push_message": "🔧 Тестовое уведомление — ваша система уведомлений работает!",
"user_no_device": "ℹ️ К этому пользователю не привязано устройство Companion — его напоминания уходят в общий сервис.",
},
"uk": {
"success": "✅ Тестове сповіщення надіслано — служба працює. Якщо певний пристрій нічого не отримує, перевірте його у вашій notify-групі (та в журналах HA).",
"no_service": "⚠️ Службу сповіщень не налаштовано. Спочатку вкажіть службу в загальних налаштуваннях.",
"invalid_service": "❌ Невірний формат служби сповіщень. Використовуйте 'notify.service_name'.",
"failed": "❌ Не вдалося надіслати тестове сповіщення. Перевірте конфігурацію служби.",
"push_message": "🔧 Тестове сповіщення — ваші сповіщення працюють!",
"user_no_device": "ℹ️ До цього користувача не прив'язано пристрій Companion — його нагадування надходять до загальної служби.",
},
"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!",
"user_no_device": "ℹ️ Este utilizador não tem nenhum dispositivo Companion associado — os lembretes seguem para o serviço da casa.",
},
"zh": {
"success": "✅ 测试通知已发送 — 您的服务正常。如果某个设备未收到,请在您的 notify 群组中检查该设备(以及 HA 日志)。",
"no_service": "⚠️ 未配置通知服务。请先在“通用设置”中配置服务。",
"invalid_service": "❌ 通知服务格式无效。请使用 'notify.服务名称' 格式。",
"failed": "❌ 测试通知发送失败。请验证您的服务配置。",
"push_message": "🔧 测试通知 — 您的通知设置已生效!",
"user_no_device": "ℹ️ 该用户未关联 Companion 设备 — 其提醒将发送到家庭通知服务。",
},
"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!",
"user_no_device": "ℹ️ Este usuário não tem nenhum dispositivo Companion vinculado — os lembretes vão para o serviço da casa.",
},
"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!",
"user_no_device": "ℹ️ Ehhez a felhasználóhoz nincs Companion eszköz rendelve — az emlékeztetői a háztartási szolgáltatásra mennek.",
},
"ko": {
"success": "✅ 테스트 알림을 보냈습니다 — 서비스가 작동합니다. 특정 기기에 알림이 오지 않으면 알림 그룹 내 해당 기기와 Home Assistant 로그를 확인하세요.",
"no_service": "⚠️ 알림 서비스가 설정되지 않았습니다. 먼저 일반 설정에서 서비스를 설정하세요.",
"invalid_service": "❌ 알림 서비스 형식이 잘못되었습니다. 'notify.service_name' 형식을 사용하세요.",
"failed": "❌ 테스트 알림 전송에 실패했습니다. 서비스 설정을 확인하세요.",
"push_message": "🔧 테스트 알림 — 알림 설정이 정상 작동합니다!",
"user_no_device": "ℹ️ 이 사용자에게 연결된 Companion 기기가 없습니다 — 알림은 가정 서비스로 전송됩니다.",
},
"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!",
"user_no_device": "ℹ️ Bu kullanıcıya bağlı bir Companion cihazı yok — hatırlatıcıları ev servisine gider.",
},
"cs": {
"success": "✅ Testovací oznámení odesláno — vaše služba funguje. Pokud konkrétní zařízení nic nedostane, zkontrolujte ho ve své notify skupině (a v protokolu HA).",
"no_service": "⚠️ Není nakonfigurována žádná oznamovací služba. Nejprve ji nastavte v Obecných nastaveních.",
"invalid_service": "❌ Formát oznamovací služby je neplatný. Použijte 'notify.nazev_sluzby'.",
"failed": "❌ Testovací oznámení se nepodařilo odeslat. Zkontrolujte prosím svou konfiguraci.",
"push_message": "🔧 Testovací oznámení — vaše nastavení oznámení funguje!",
"user_no_device": "ℹ️ Tento uživatel nemá přiřazené žádné Companion zařízení — jeho připomínky půjdou na službu domácnosti.",
},
"da": {
"success": "✅ Testnotifikation sendt — din tjeneste virker. Hvis en bestemt enhed intet modtager, så tjek den enhed i din notify-gruppe (og HA-loggen).",
"no_service": "⚠️ Ingen notifikationstjeneste konfigureret. Opsæt først en tjeneste under Generelle indstillinger.",
"invalid_service": "❌ Notifikationstjenestens format er ugyldigt. Brug 'notify.tjenestenavn'.",
"failed": "❌ Testnotifikationen kunne ikke sendes. Tjek venligst din konfiguration.",
"push_message": "🔧 Testnotifikation — din notifikationsopsætning virker!",
"user_no_device": "ℹ️ Denne bruger har ingen tilknyttet Companion-enhed — deres påmindelser går til husstandens tjeneste.",
},
"fi": {
"success": "✅ Testi-ilmoitus lähetetty — palvelusi toimii. Jos jokin laite ei saa mitään, tarkista se notify-ryhmästäsi (ja HA-lokista).",
"no_service": "⚠️ Ilmoituspalvelua ei ole määritetty. Määritä palvelu ensin Yleisissä asetuksissa.",
"invalid_service": "❌ Ilmoituspalvelun muoto on virheellinen. Käytä muotoa 'notify.palvelunnimi'.",
"failed": "❌ Testi-ilmoituksen lähetys epäonnistui. Tarkista määrityksesi.",
"push_message": "🔧 Testi-ilmoitus — ilmoitusasetuksesi toimivat!",
"user_no_device": "ℹ️ Tällä käyttäjällä ei ole liitettyä Companion-laitetta — hänen muistutuksensa menevät talouden palveluun.",
},
"hi": {
"success": "✅ परीक्षण सूचना भेजी गई — आपकी सेवा काम कर रही है। यदि किसी विशेष डिवाइस को कुछ नहीं मिलता, तो अपनी notify समूह में उस डिवाइस की जाँच करें (और HA लॉग)।",
"no_service": "⚠️ कोई सूचना सेवा कॉन्फ़िगर नहीं है। कृपया पहले सामान्य सेटिंग्स में एक सेवा सेट करें।",
"invalid_service": "❌ सूचना सेवा का प्रारूप अमान्य है। 'notify.servicename' का उपयोग करें।",
"failed": "❌ परीक्षण सूचना भेजी नहीं जा सकी। कृपया अपनी कॉन्फ़िगरेशन जाँचें।",
"push_message": "🔧 परीक्षण सूचना — आपकी सूचना व्यवस्था काम कर रही है!",
"user_no_device": "ℹ️ इस उपयोगकर्ता से कोई Companion डिवाइस संबद्ध नहीं है — उनकी याद दिलाने वाली सूचनाएँ घरेलू सेवा पर जाएँगी।",
},
"ja": {
"success": "✅ テスト通知を送信しました — サービスは動作しています。特定のデバイスに届かない場合は、notify グループ内のそのデバイス(と HA ログ)を確認してください。",
"no_service": "⚠️ 通知サービスが設定されていません。まず一般設定でサービスを設定してください。",
"invalid_service": "❌ 通知サービスの形式が無効です。'notify.サービス名' を使用してください。",
"failed": "❌ テスト通知を送信できませんでした。設定を確認してください。",
"push_message": "🔧 テスト通知 — 通知の設定は正常に動作しています!",
"user_no_device": "ℹ️ このユーザーには Companion デバイスが関連付けられていません — リマインダーは世帯のサービスに送られます。",
},
"nb": {
"success": "✅ Testvarsel sendt — tjenesten din fungerer. Hvis en bestemt enhet ikke mottar noe, sjekk den enheten i notify-gruppen din (og HA-loggen).",
"no_service": "⚠️ Ingen varslingstjeneste konfigurert. Sett opp en tjeneste under Generelle innstillinger først.",
"invalid_service": "❌ Varslingstjenestens format er ugyldig. Bruk 'notify.tjenestenavn'.",
"failed": "❌ Testvarselet kunne ikke sendes. Sjekk konfigurasjonen din.",
"push_message": "🔧 Testvarsel — varslingsoppsettet ditt fungerer!",
"user_no_device": "ℹ️ Denne brukeren har ingen tilknyttet Companion-enhet — påminnelsene deres går til husstandens tjeneste.",
},
"pl": {
"success": "✅ Powiadomienie testowe wysłane — twoja usługa działa. Jeśli konkretne urządzenie nic nie otrzymuje, sprawdź je w swojej grupie notify (i w logu HA).",
"no_service": "⚠️ Nie skonfigurowano usługi powiadomień. Najpierw ustaw usługę w Ustawieniach ogólnych.",
"invalid_service": "❌ Format usługi powiadomień jest nieprawidłowy. Użyj 'notify.nazwa_uslugi'.",
"failed": "❌ Nie udało się wysłać powiadomienia testowego. Sprawdź swoją konfigurację.",
"push_message": "🔧 Powiadomienie testowe — twoja konfiguracja powiadomień działa!",
"user_no_device": "ℹ️ Ten użytkownik nie ma przypisanego urządzenia Companion — jego przypomnienia trafią do usługi domowej.",
},
"sv": {
"success": "✅ Testnotis skickad — din tjänst fungerar. Om en viss enhet inte får något, kontrollera den enheten i din notify-grupp (och HA-loggen).",
"no_service": "⚠️ Ingen notistjänst konfigurerad. Konfigurera först en tjänst under Allmänna inställningar.",
"invalid_service": "❌ Notistjänstens format är ogiltigt. Använd 'notify.tjanstnamn'.",
"failed": "❌ Testnotisen kunde inte skickas. Kontrollera din konfiguration.",
"push_message": "🔧 Testnotis — din notiskonfiguration fungerar!",
"user_no_device": "ℹ️ Den här användaren har ingen kopplad Companion-enhet — deras påminnelser går till hushållets tjänst.",
},
}
# 2.94: labels the options flow builds at runtime (the detected dwelling in the
# home-profile step, the "all tasks" choice of the notification scope) — the
# static texts live in strings.json like every other step.
_SETTINGS_STRINGS: dict[str, dict[str, str]] = {
"en": {
"dwelling_house": "house",
"dwelling_apartment": "apartment",
"dwelling_unknown": "not sure yet",
"scope_all": "All tasks",
"region_auto": "Automatic ({region})",
"region_none": "not found",
},
"de": {
"dwelling_house": "Haus",
"dwelling_apartment": "Wohnung",
"dwelling_unknown": "noch unklar",
"scope_all": "Alle Aufgaben",
"region_auto": "Automatisch ({region})",
"region_none": "nicht gefunden",
},
"cs": {
"dwelling_house": "dům",
"dwelling_apartment": "byt",
"dwelling_unknown": "zatím nejasné",
"scope_all": "Všechny úkoly",
"region_auto": "Automaticky ({region})",
"region_none": "nenalezeno",
},
"da": {
"dwelling_house": "hus",
"dwelling_apartment": "lejlighed",
"dwelling_unknown": "endnu uklart",
"scope_all": "Alle opgaver",
"region_auto": "Automatisk ({region})",
"region_none": "ikke fundet",
},
"es": {
"dwelling_house": "casa",
"dwelling_apartment": "piso",
"dwelling_unknown": "aún sin determinar",
"scope_all": "Todas las tareas",
"region_auto": "Automático ({region})",
"region_none": "no encontrada",
},
"fi": {
"dwelling_house": "talo",
"dwelling_apartment": "kerrostaloasunto",
"dwelling_unknown": "vielä epäselvä",
"scope_all": "Kaikki tehtävät",
"region_auto": "Automaattinen ({region})",
"region_none": "ei löytynyt",
},
"fr": {
"dwelling_house": "maison",
"dwelling_apartment": "appartement",
"dwelling_unknown": "pas encore déterminé",
"scope_all": "Toutes les tâches",
"region_auto": "Automatique ({region})",
"region_none": "introuvable",
},
"hi": {"dwelling_house": "मकान", "dwelling_apartment": "फ़्लैट", "dwelling_unknown": "अभी तय नहीं", "scope_all": "सभी कार्य", "region_auto": "स्वचालित ({region})", "region_none": "नहीं मिला"},
"hu": {
"dwelling_house": "ház",
"dwelling_apartment": "lakás",
"dwelling_unknown": "még nem egyértelmű",
"scope_all": "Minden feladat",
"region_auto": "Automatikus ({region})",
"region_none": "nem található",
},
"it": {
"dwelling_house": "casa",
"dwelling_apartment": "appartamento",
"dwelling_unknown": "non ancora chiaro",
"scope_all": "Tutte le attività",
"region_auto": "Automatico ({region})",
"region_none": "non trovata",
},
"ja": {
"dwelling_house": "一戸建て",
"dwelling_apartment": "集合住宅",
"dwelling_unknown": "まだ不明",
"scope_all": "すべてのタスク",
"region_auto": "自動({region})",
"region_none": "見つかりません",
},
"ko": {
"dwelling_house": "단독주택",
"dwelling_apartment": "아파트",
"dwelling_unknown": "아직 불확실",
"scope_all": "모든 작업",
"region_auto": "자동({region})",
"region_none": "찾을 수 없음",
},
"nb": {
"dwelling_house": "hus",
"dwelling_apartment": "leilighet",
"dwelling_unknown": "ennå uklart",
"scope_all": "Alle oppgaver",
"region_auto": "Automatisk ({region})",
"region_none": "ikke funnet",
},
"nl": {
"dwelling_house": "huis",
"dwelling_apartment": "appartement",
"dwelling_unknown": "nog onduidelijk",
"scope_all": "Alle taken",
"region_auto": "Automatisch ({region})",
"region_none": "niet gevonden",
},
"pl": {
"dwelling_house": "dom",
"dwelling_apartment": "mieszkanie",
"dwelling_unknown": "jeszcze niejasne",
"scope_all": "Wszystkie zadania",
"region_auto": "Automatycznie ({region})",
"region_none": "nie znaleziono",
},
"pt": {
"dwelling_house": "moradia",
"dwelling_apartment": "apartamento",
"dwelling_unknown": "ainda por determinar",
"scope_all": "Todas as tarefas",
"region_auto": "Automático ({region})",
"region_none": "não encontrada",
},
"pt-br": {
"dwelling_house": "casa",
"dwelling_apartment": "apartamento",
"dwelling_unknown": "ainda indefinido",
"scope_all": "Todas as tarefas",
"region_auto": "Automático ({region})",
"region_none": "não encontrada",
},
"ru": {
"dwelling_house": "дом",
"dwelling_apartment": "квартира",
"dwelling_unknown": "пока неясно",
"scope_all": "Все задачи",
"region_auto": "Автоматически ({region})",
"region_none": "не найден",
},
"sv": {
"dwelling_house": "hus",
"dwelling_apartment": "lägenhet",
"dwelling_unknown": "ännu oklart",
"scope_all": "Alla uppgifter",
"region_auto": "Automatiskt ({region})",
"region_none": "hittades inte",
},
"tr": {
"dwelling_house": "müstakil ev",
"dwelling_apartment": "daire",
"dwelling_unknown": "henüz belirsiz",
"scope_all": "Tüm görevler",
"region_auto": "Otomatik ({region})",
"region_none": "bulunamadı",
},
"uk": {
"dwelling_house": "будинок",
"dwelling_apartment": "квартира",
"dwelling_unknown": "поки неясно",
"scope_all": "Усі завдання",
"region_auto": "Автоматично ({region})",
"region_none": "не знайдено",
},
"zh": {"dwelling_house": "独栋住宅", "dwelling_apartment": "公寓", "dwelling_unknown": "尚不确定", "scope_all": "所有任务", "region_auto": "自动({region})", "region_none": "未找到"},
}
def _settings_text(hass: HomeAssistant, key: str) -> str:
return _SETTINGS_STRINGS.get(normalize_language(hass), _SETTINGS_STRINGS["en"])[key]
def parse_lead_days(raw: Any) -> list[int] | None:
"""``"14, 3, 0"`` → ``[14, 3, 0]`` (days before due, 0–365); None when a
piece is not such a number. Blank = no extra reminders."""
leads: list[int] = []
for piece in str(raw or "").replace(";", ",").split(","):
piece = piece.strip()
if not piece:
continue
if not piece.isdigit() or int(piece) > 365:
return None
leads.append(int(piece))
return leads[: MAX_REMINDER_LEADS * 2]
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],
user_id: str | None = None,
) -> str:
"""Send a test notification, optionally to ONE household member.
Returns a result key ("success", "no_service", "invalid_service",
"failed", "user_no_device") 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.
With ``user_id`` the target is resolved through the SAME lookup the real
per-task routing uses (``get_user_notify_services``). That is the whole
point of a per-user test: one that took its own path could report success
while real reminders still went somewhere else — which is exactly how the
wrong-service bug behind #75 stayed invisible.
"""
if user_id:
from .helpers.notification_manager import get_user_notify_services
user_services = await get_user_notify_services(hass, user_id)
if not user_services:
# Not a failure: this member simply has no Companion device, so
# their reminders fall back to the household service. Saying so is
# more useful than sending a test that proves nothing.
return "user_no_device"
return await _send_test_to(hass, options, user_services)
notify_service = str(options.get(CONF_NOTIFY_SERVICE, ""))
if not notify_service:
if options.get(CONF_NOTIFY_EVENT_ONLY, False):
# #173: event-only needs no service — the test fires the event
# so the user's automation can be checked from Settings.
return await _send_test_to(hass, options, [""])
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"
return await _send_test_to(hass, options, [normalized])
async def _send_test_to(
hass: HomeAssistant,
options: dict[str, Any],
services: list[str],
) -> str:
"""Send the test payload to every resolved service; "success" if any went."""
try:
from .helpers.notification_manager import build_action_buttons
from .helpers.notify_hooks import async_emit_and_dispatch, sample_notification_context
push_msg = _get_test_result_text(hass, "push_message")
service_data: dict[str, Any] = {
"title": "Maintenance Supporter",
"message": push_msg,
}
# The same buttons (translated, per the action toggles) a real
# reminder carries \u2014 test ids, which the action listener ignores.
test_actions = build_action_buttons(hass, options, normalize_language(hass), entry_id=None, task_id=None, skip_allowed=True)
if test_actions:
service_data["data"] = {"actions": test_actions}
# Dual-path: legacy notify service OR notify entity (send_message).
# #165: the test walks the same hook as a real notification, so the
# event and the extra-data template can be verified from Settings.
# Sample values for EVERY field of the contract (#178), so a template
# can be written and verified from Settings before a real reminder fires.
context = sample_notification_context(hass)
sent_any = False
for service in services:
if await async_emit_and_dispatch(hass, service, service_data, context, blocking=True):
sent_any = True
return "success" if sent_any else "failed"
except Exception: # noqa: BLE001 - any failure mode reports "failed" to the UI
_LOGGER.debug("Test notification failed for %s", services, 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 _opt(self, key: str) -> Any:
"""The stored value of a setting, or its registry default — every form
``default=`` goes through here so the flow can't drift from the WS
``settings`` echo (tests/test_settings_defaults.py)."""
return self._current.get(key, setting_default(key))
def _save_and_return(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Merge user input into options and return to the menu."""
from .helpers.settings_registry import ALLOWED_SETTING_KEYS
merged = self._current
# HA's NumberSelector hands back floats; the settings registry (WS
# writes, export/import) types these keys as int. Store what the
# registry expects so a settings backup round-trips (bug audit
# 2026-09-12).
for key, value in list(user_input.items()):
if ALLOWED_SETTING_KEYS.get(key) is int and isinstance(value, float) and value.is_integer():
user_input[key] = int(value)
# The TimeSelector hands back "HH:MM:SS"; the registry caps the quiet
# hours at 5 chars, so an 8-char value was dropped on a settings
# export/import. Store the canonical "HH:MM" (an unparseable value
# keeps the previous setting — the form's default coerces it anyway).
for key in (CONF_QUIET_HOURS_START, CONF_QUIET_HOURS_END):
if key in user_input:
normalized = normalize_hhmm(user_input[key])
if normalized is None:
user_input.pop(key)
else:
user_input[key] = normalized
# 2.94: the one sanitiser the panel's writes go through (lists, maps,
# enums, ranges) — the flow now edits every setting the panel does,
# and the two write paths must not drift. The steps validated the
# notify service / shopping list / search template themselves; a
# value the sanitiser drops is not saved.
from .websocket.dashboard import sanitize_settings_input
registered = {k: v for k, v in user_input.items() if k in ALLOWED_SETTING_KEYS}
cleaned, _error = sanitize_settings_input(registered)
user_input = {**{k: v for k, v in user_input.items() if k not in ALLOWED_SETTING_KEYS}, **cleaned}
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
# 2.94: every setting the panel edits has a home here too
# (tests/test_global_settings_parity.py).
options = [
"general_settings",
"advanced_features",
"panel_access",
"home_profile",
"archive_settings",
"member_avatars",
"battery_lifetimes",
]
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)
return self.async_show_form(
step_id="advanced_features",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ADVANCED_ADAPTIVE,
default=self._opt(CONF_ADVANCED_ADAPTIVE),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_PREDICTIONS,
default=self._opt(CONF_ADVANCED_PREDICTIONS),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SEASONAL,
default=self._opt(CONF_ADVANCED_SEASONAL),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_ENVIRONMENTAL,
default=self._opt(CONF_ADVANCED_ENVIRONMENTAL),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_BUDGET,
default=self._opt(CONF_ADVANCED_BUDGET),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_GROUPS,
default=self._opt(CONF_ADVANCED_GROUPS),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_CHECKLISTS,
default=self._opt(CONF_ADVANCED_CHECKLISTS),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SCHEDULE_TIME,
default=self._opt(CONF_ADVANCED_SCHEDULE_TIME),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_COMPLETION_ACTIONS,
default=self._opt(CONF_ADVANCED_COMPLETION_ACTIONS),
): 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
]
return self.async_show_form(
step_id="panel_access",
data_schema=vol.Schema(
{
vol.Optional(
CONF_OPERATOR_WRITE_ENABLED,
default=self._opt(CONF_OPERATOR_WRITE_ENABLED),
): selector.BooleanSelector(),
# A deleted user or one promoted to admin is no longer an
# option — keeping it as default made the step unsaveable
# (bug audit 2026-09-26); it drops out on the next save.
vol.Optional(
CONF_ADMIN_PANEL_USER_IDS,
default=select_default(self._opt(CONF_ADMIN_PANEL_USER_IDS) or [], options),
): 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
# Shopping-list target: a cleared EntitySelector omits the key —
# normalise to "" so clearing actually turns the sync off. Format
# only (todo.*): the list's integration may load later.
raw_list = (user_input.get(CONF_SHOPPING_LIST_ENTITY) or "").strip()
if raw_list and not raw_list.startswith("todo."):
errors[CONF_SHOPPING_LIST_ENTITY] = "invalid_shopping_list_entity"
user_input[CONF_SHOPPING_LIST_ENTITY] = raw_list
# Trim + cap the optional sidebar title; blank clears the override
# (panel falls back to the default "Maintenance").
# An emptied field arrives ABSENT (the frontend drops it) and the
# options merge kept the old title — map absence to "" so blank
# really clears (bug audit 2026-09-26).
raw_title = user_input.get(CONF_PANEL_TITLE)
user_input[CONF_PANEL_TITLE] = (raw_title if isinstance(raw_title, str) else "").strip()[:MAX_PANEL_TITLE_LENGTH]
# D#182: shopping-search template — blank = automatic (country,
# then UI language); otherwise an http(s) URL with {q}.
from .helpers.parts import valid_search_template
raw_tpl = (user_input.get(CONF_PART_SEARCH_URL_TEMPLATE) or "").strip()
if raw_tpl and not valid_search_template(raw_tpl):
errors[CONF_PART_SEARCH_URL_TEMPLATE] = "invalid_search_template"
user_input[CONF_PART_SEARCH_URL_TEMPLATE] = raw_tpl
# The panel reorders columns; a checkbox list cannot. Keep the
# saved order for the columns still ticked, append new ones.
if CONF_OBJECTS_TABLE_COLUMNS in user_input:
picked = list(user_input[CONF_OBJECTS_TABLE_COLUMNS] or [])
kept = [c for c in self._opt(CONF_OBJECTS_TABLE_COLUMNS) if c in picked]
columns = kept + [c for c in picked if c not in kept]
# The name column is not optional (the panel greys it out).
user_input[CONF_OBJECTS_TABLE_COLUMNS] = columns if "name" in columns else ["name", *columns]
if not errors:
return self._save_and_return(user_input)
current = self._current
currency_code = self._opt(CONF_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=self._opt(CONF_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,
)
),
# Decimal places for every displayed amount (0 = whole numbers).
vol.Optional(
CONF_CURRENCY_DECIMALS,
default=self._opt(CONF_CURRENCY_DECIMALS),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(CONF_CURRENCY_DECIMALS)[0],
max=int_range(CONF_CURRENCY_DECIMALS)[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
# #146 / #180: the battery fleet's low floor and the level a
# low battery must rise above to count as replaced.
vol.Optional(
CONF_BATTERY_LOW_PERCENT,
default=self._opt(CONF_BATTERY_LOW_PERCENT),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(CONF_BATTERY_LOW_PERCENT)[0],
max=int_range(CONF_BATTERY_LOW_PERCENT)[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_BATTERY_RECOVERED_PERCENT,
default=self._opt(CONF_BATTERY_RECOVERED_PERCENT),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(CONF_BATTERY_RECOVERED_PERCENT)[0],
max=int_range(CONF_BATTERY_RECOVERED_PERCENT)[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
# #181 follow-up (advanced): a level-driven recovery records
# the replacement in Battery Notes and consumes the cells.
vol.Optional(
CONF_BATTERY_AUTO_RECORD_RECOVERY,
default=self._opt(CONF_BATTERY_AUTO_RECORD_RECOVERY),
): selector.BooleanSelector(),
# #146: the household "low" floor for consumables.
vol.Optional(
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
default=self._opt(CONF_DEFAULT_CONSUMABLE_THRESHOLD),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(CONF_DEFAULT_CONSUMABLE_THRESHOLD)[0],
max=int_range(CONF_DEFAULT_CONSUMABLE_THRESHOLD)[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="%",
)
),
# #145 / #170: how task rows look.
vol.Optional(
CONF_ROW_ACTION_STYLE,
default=self._opt(CONF_ROW_ACTION_STYLE),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(ROW_ACTION_STYLES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="row_action_style",
)
),
vol.Optional(
CONF_REF_NUMBERS_IN_LISTS,
default=self._opt(CONF_REF_NUMBERS_IN_LISTS),
): selector.BooleanSelector(),
# #67: the objects table's columns (the panel also orders them).
vol.Optional(
CONF_OBJECTS_TABLE_COLUMNS,
default=select_default(self._opt(CONF_OBJECTS_TABLE_COLUMNS), KNOWN_OBJECT_TABLE_COLUMNS),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(KNOWN_OBJECT_TABLE_COLUMNS),
multiple=True,
mode=selector.SelectSelectorMode.LIST,
translation_key="objects_table_columns",
)
),
vol.Optional(
CONF_INSTALL_ASSIST_SENTENCES,
default=self._opt(CONF_INSTALL_ASSIST_SENTENCES),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFICATIONS_ENABLED,
default=self._opt(CONF_NOTIFICATIONS_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_SERVICE,
default=self._opt(CONF_NOTIFY_SERVICE),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=notify_services,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
# v2.67: mirror auto "buy" tasks into this to-do list
# (blank = off). suggested_value keeps the field clearable.
vol.Optional(
CONF_SHOPPING_LIST_ENTITY,
description={"suggested_value": current.get(CONF_SHOPPING_LIST_ENTITY, "")},
): selector.EntitySelector(selector.EntitySelectorConfig(domain="todo")),
vol.Optional(
CONF_PANEL_ENABLED,
default=self._opt(CONF_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)),
# D#182: shopping-search template with {q}; blank = automatic
# (Amazon store of the HA country, then of the UI language).
vol.Optional(
CONF_PART_SEARCH_URL_TEMPLATE,
description={"suggested_value": current.get(CONF_PART_SEARCH_URL_TEMPLATE, "")},
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.URL)),
}
),
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."""
errors: dict[str, str] = {}
if user_input is not None:
# Same cap as the WS settings path (settings registry max_len).
# The frontend leaves an emptied field OUT of the submission, so
# absence means "cleared" — the merge in _save_and_return would
# otherwise keep the old template (bug audit 2026-09-26).
extra = user_input.get(CONF_NOTIFY_EXTRA_DATA)
user_input[CONF_NOTIFY_EXTRA_DATA] = (extra if isinstance(extra, str) else "")[:MAX_NOTIFY_EXTRA_DATA_LENGTH]
# 2.94: extra reminders "14, 3, 0" days before due (absent = none).
leads = parse_lead_days(user_input.get(CONF_REMINDER_LEAD_DAYS))
if leads is None:
errors[CONF_REMINDER_LEAD_DAYS] = "invalid_reminder_lead_days"
else:
user_input[CONF_REMINDER_LEAD_DAYS] = leads
# "" = every task; a saved view narrows the reminders to its tasks.
if user_input.get(CONF_NOTIFY_SCOPE_VIEW_ID) == _SCOPE_ALL:
user_input[CONF_NOTIFY_SCOPE_VIEW_ID] = ""
if not errors:
return self._save_and_return(user_input)
current = self._current
from .helpers.saved_views import list_saved_views
scope_options = [selector.SelectOptionDict(value=_SCOPE_ALL, label=_settings_text(self.hass, "scope_all"))]
scope_options += [
selector.SelectOptionDict(value=str(v["id"]), label=str(v.get("name") or v["id"]))
for v in list_saved_views(self.hass)
if v.get("id")
]
scope = self._opt(CONF_NOTIFY_SCOPE_VIEW_ID) or _SCOPE_ALL
if scope not in {o["value"] for o in scope_options}:
scope = _SCOPE_ALL # the view was deleted — the setting falls back to all tasks
return self.async_show_form(
step_id="notification_settings",
data_schema=vol.Schema(
{
# --- Due Soon ---
vol.Optional(
CONF_NOTIFY_DUE_SOON_ENABLED,
default=self._opt(CONF_NOTIFY_DUE_SOON_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_DUE_SOON_INTERVAL,
default=self._opt(CONF_NOTIFY_DUE_SOON_INTERVAL),
): 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=self._opt(CONF_NOTIFY_OVERDUE_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_OVERDUE_INTERVAL,
default=self._opt(CONF_NOTIFY_OVERDUE_INTERVAL),
): 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=self._opt(CONF_NOTIFY_TRIGGERED_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_TRIGGERED_INTERVAL,
default=self._opt(CONF_NOTIFY_TRIGGERED_INTERVAL),
): 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=self._opt(CONF_QUIET_HOURS_ENABLED),
): 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), setting_default(CONF_QUIET_HOURS_START)),
): selector.TimeSelector(),
vol.Optional(
CONF_QUIET_HOURS_END,
default=_safe_time(current.get(CONF_QUIET_HOURS_END), setting_default(CONF_QUIET_HOURS_END)),
): selector.TimeSelector(),
# --- Daily Limit ---
vol.Optional(
CONF_MAX_NOTIFICATIONS_PER_DAY,
default=self._opt(CONF_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=self._opt(CONF_NOTIFICATION_BUNDLING_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
default=self._opt(CONF_NOTIFICATION_BUNDLE_THRESHOLD),
): 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=self._opt(CONF_NOTIFICATION_TITLE_STYLE),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["default", "object_name", "task_name"],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="notification_title_style",
)
),
# #173 follow-up: completion notifications (activity kind).
vol.Optional(
CONF_NOTIFY_COMPLETED,
default=self._opt(CONF_NOTIFY_COMPLETED),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(NOTIFY_COMPLETED_MODES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="notify_completed",
)
),
# 2.80 (#165) / #173: your own notification rule — the same
# two settings the panel's Settings tab offers.
vol.Optional(
CONF_NOTIFY_EVENT_ONLY,
default=self._opt(CONF_NOTIFY_EVENT_ONLY),
): selector.BooleanSelector(),
# suggested_value, not default: HA's form drops an emptied
# field from the submission and a default re-inserted the
# old template, so it could never be cleared (bug audit
# 2026-09-26; the handler maps absence to "").
vol.Optional(
CONF_NOTIFY_EXTRA_DATA,
description={"suggested_value": self._opt(CONF_NOTIFY_EXTRA_DATA)},
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True)),
# --- 2.94: the rest of the panel's notification rows ---
vol.Optional(
CONF_REMINDER_LEAD_DAYS,
description={"suggested_value": ", ".join(str(d) for d in self._opt(CONF_REMINDER_LEAD_DAYS))},
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
# suggested_value: the "every task" choice is a sentinel,
# not the registry default "" (tests/test_settings_defaults.py).
vol.Optional(
CONF_NOTIFY_SCOPE_VIEW_ID,
description={"suggested_value": scope},
): selector.SelectSelector(
selector.SelectSelectorConfig(options=scope_options, mode=selector.SelectSelectorMode.DROPDOWN)
),
vol.Optional(
CONF_WEEKLY_DIGEST_ENABLED,
default=self._opt(CONF_WEEKLY_DIGEST_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_WARRANTY_REMINDER_ENABLED,
default=self._opt(CONF_WARRANTY_REMINDER_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_WARRANTY_REMINDER_DAYS,
default=self._opt(CONF_WARRANTY_REMINDER_DAYS),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(CONF_WARRANTY_REMINDER_DAYS)[0],
max=int_range(CONF_WARRANTY_REMINDER_DAYS)[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
}
),
errors=errors,
)
# --- 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)
return self.async_show_form(
step_id="notification_actions",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ACTION_COMPLETE_ENABLED,
default=self._opt(CONF_ACTION_COMPLETE_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SKIP_ENABLED,
default=self._opt(CONF_ACTION_SKIP_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SNOOZE_ENABLED,
default=self._opt(CONF_ACTION_SNOOZE_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_SNOOZE_DURATION_HOURS,
default=self._opt(CONF_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},
)
# --- Home profile & templates (2.94) ---
async def async_step_home_profile(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""House or apartment (the recommendations follow it), the state or
province whose rules the templates follow (where the country has
such rules), and the templates hidden from the pickers — the panel's
Home profile and Templates sections."""
if user_input is not None:
user_input.setdefault(CONF_DISABLED_TEMPLATE_IDS, [])
return self._save_and_return(user_input)
from .helpers.home_profile import async_home_profile
from .helpers.region import regions_of
from .templates import TEMPLATE_CATEGORIES, TEMPLATES, localize_template_text
lang = normalize_language(self.hass)
profile = await async_home_profile(self.hass)
detected = profile.dwelling_detected if profile.dwelling_detected in ("house", "apartment") else "unknown"
climate = profile.climate
order = {cat_id: i for i, cat_id in enumerate(TEMPLATE_CATEGORIES)}
templates = sorted(TEMPLATES, key=lambda t: order.get(t.category, len(order)))
template_options = [
selector.SelectOptionDict(
value=t.id,
label=(
f"{TEMPLATE_CATEGORIES.get(t.category, {}).get(f'name_{lang}', TEMPLATE_CATEGORIES.get(t.category, {}).get('name_en', t.category))}"
f" › {localize_template_text(t.name, lang) or t.name}"
),
)
for t in templates
]
fields: dict[Any, Any] = {
vol.Optional(
CONF_HOME_TYPE,
default=self._opt(CONF_HOME_TYPE) if self._opt(CONF_HOME_TYPE) in HOME_TYPES else "auto",
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(HOME_TYPES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="home_type",
)
),
}
regions = regions_of(profile.country, dict(profile.region_names))
if regions:
codes = {r["code"] for r in regions}
detected_name = profile.region_names.get(profile.region_detected or "") or _settings_text(self.hass, "region_none")
fields[
vol.Optional(
CONF_HOME_REGION,
default=self._opt(CONF_HOME_REGION) if self._opt(CONF_HOME_REGION) in codes else HOME_REGION_AUTO,
)
] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=HOME_REGION_AUTO, label=_settings_text(self.hass, "region_auto").replace("{region}", detected_name)
),
*(selector.SelectOptionDict(value=r["code"], label=r["name"]) for r in regions),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
return self.async_show_form(
step_id="home_profile",
data_schema=vol.Schema(
{
**fields,
vol.Optional(
CONF_DISABLED_TEMPLATE_IDS,
default=select_default(self._opt(CONF_DISABLED_TEMPLATE_IDS), template_options),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=template_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
),
description_placeholders={
"detected": _settings_text(self.hass, f"dwelling_{detected}"),
"country": profile.country or "–",
"climate": (climate.koppen if climate and climate.koppen else "–"),
},
)
# --- Archive automation (2.94) ---
async def async_step_archive_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Archive done one-time tasks after N days, delete archived ones after M."""
if user_input is not None:
return self._save_and_return(user_input)
return self.async_show_form(
step_id="archive_settings",
data_schema=vol.Schema(
{
vol.Optional(key, default=self._opt(key)): selector.NumberSelector(
selector.NumberSelectorConfig(
min=int_range(key)[0], max=int_range(key)[1], step=1, mode=selector.NumberSelectorMode.BOX
)
)
for key in (CONF_ARCHIVE_ONEOFF_DAYS, CONF_DELETE_ARCHIVED_ONEOFF_DAYS)
}
),
)
# --- Member avatars (2.94) ---
async def _members(self) -> list[Any]:
"""Household members as the panel lists them: active, non-system users."""
users = await self.hass.auth.async_get_users()
return [u for u in users if not u.system_generated and u.is_active]
async def async_step_member_avatars(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Pick the household member whose avatar (initials, colour) to change."""
from .helpers.member_display import member_display
members = await self._members()
if user_input is not None and user_input.get("member") in {u.id for u in members}:
self._avatar_user_id = str(user_input["member"])
return await self.async_step_member_avatar()
options = []
for u in members:
shown = member_display(self._current, u.id, u.name)
options.append(selector.SelectOptionDict(value=u.id, label=f"{u.name or u.id[:8]} ({shown.get('initials', '')})"))
return self.async_show_form(
step_id="member_avatars",
data_schema=vol.Schema(
{
vol.Required("member"): selector.SelectSelector(
selector.SelectSelectorConfig(options=options, mode=selector.SelectSelectorMode.LIST)
),
}
),
)
async def async_step_member_avatar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Initials + palette colour for one member (blank / automatic = the default)."""
from .helpers.member_display import AVATAR_COLOR_NAMES, AVATAR_PALETTE, member_display
user_id = getattr(self, "_avatar_user_id", "")
overrides = dict(self._opt(CONF_MEMBER_DISPLAY) or {})
if user_input is not None:
entry: dict[str, str] = {}
initials = str(user_input.get("initials") or "").strip()
if initials:
entry["initials"] = initials
color = user_input.get("color")
if color in AVATAR_COLOR_NAMES:
entry["color"] = AVATAR_PALETTE[AVATAR_COLOR_NAMES.index(color)]
if entry:
overrides[user_id] = entry
else:
overrides.pop(user_id, None)
return self._save_and_return({CONF_MEMBER_DISPLAY: overrides})
members = {u.id: u for u in await self._members()}
user = members.get(user_id)
if user is None:
return await self.async_step_member_avatars()
current = overrides.get(user_id, {})
color_name = (
AVATAR_COLOR_NAMES[AVATAR_PALETTE.index(current["color"])] if current.get("color") in AVATAR_PALETTE else "auto"
)
default = member_display({}, user_id, user.name)
return self.async_show_form(
step_id="member_avatar",
data_schema=vol.Schema(
{
vol.Optional("initials", description={"suggested_value": current.get("initials", "")}): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("color", default=color_name): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["auto", *AVATAR_COLOR_NAMES],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="avatar_color",
)
),
}
),
description_placeholders={"member": user.name or user_id[:8], "initials": default.get("initials", "")},
)
# --- Battery lifetimes (2.94) ---
async def async_step_battery_lifetimes(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Typical lifetime in months per battery type — the forecast for
batteries without a level sensor. One field per type (the type is the
label); blank = the built-in value."""
from .websocket.dashboard import _battery_lifetime_catalog
rows = _battery_lifetime_catalog(self.hass)
types = [str(r["type"]) for r in rows]
if user_input is not None:
overrides = {t: user_input[t] for t in types if isinstance(user_input.get(t), (int, float))}
return self._save_and_return({CONF_BATTERY_LIFETIME_MONTHS: overrides})
from .helpers.battery_lifetime import MAX_LIFETIME_MONTHS, MIN_LIFETIME_MONTHS
return self.async_show_form(
step_id="battery_lifetimes",
data_schema=vol.Schema(
{
vol.Optional(
str(r["type"]), description={"suggested_value": r.get("override_months")}
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=MIN_LIFETIME_MONTHS, max=MAX_LIFETIME_MONTHS, step=1, mode=selector.NumberSelectorMode.BOX
)
)
for r in rows
}
),
description_placeholders={
"defaults": " · ".join(f"{r['type']} {r['default_months']}" for r in rows),
},
)
# --- 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)
currency_code = self._opt(CONF_BUDGET_CURRENCY)
currency_symbol = BUDGET_CURRENCIES.get(currency_code, BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY])
return self.async_show_form(
step_id="budget_settings",
data_schema=vol.Schema(
{
vol.Optional(
CONF_BUDGET_MONTHLY,
default=self._opt(CONF_BUDGET_MONTHLY),
): 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=self._opt(CONF_BUDGET_YEARLY),
): 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=self._opt(CONF_BUDGET_ALERTS_ENABLED),
): selector.BooleanSelector(),
vol.Optional(
CONF_BUDGET_ALERT_THRESHOLD,
default=self._opt(CONF_BUDGET_ALERT_THRESHOLD),
): 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(),
)