New apps Added
This commit is contained in:
@@ -0,0 +1,835 @@
|
||||
"""Global options flow for the Maintenance Supporter integration.
|
||||
|
||||
Contains GlobalOptionsFlow: menu-based global settings (notifications, budget, groups).
|
||||
Split from config_flow_options.py for better maintainability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
BUDGET_CURRENCIES,
|
||||
CONF_ACTION_COMPLETE_ENABLED,
|
||||
CONF_ACTION_SKIP_ENABLED,
|
||||
CONF_ACTION_SNOOZE_ENABLED,
|
||||
CONF_ADMIN_PANEL_USER_IDS,
|
||||
CONF_ADVANCED_ADAPTIVE,
|
||||
CONF_ADVANCED_BUDGET,
|
||||
CONF_ADVANCED_CHECKLISTS,
|
||||
CONF_ADVANCED_ENVIRONMENTAL,
|
||||
CONF_ADVANCED_GROUPS,
|
||||
CONF_ADVANCED_PREDICTIONS,
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
CONF_BUDGET_MONTHLY,
|
||||
CONF_BUDGET_YEARLY,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_MAX_NOTIFICATIONS_PER_DAY,
|
||||
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
|
||||
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
||||
CONF_NOTIFICATION_TITLE_STYLE,
|
||||
CONF_NOTIFICATIONS_ENABLED,
|
||||
CONF_NOTIFY_DUE_SOON_ENABLED,
|
||||
CONF_NOTIFY_DUE_SOON_INTERVAL,
|
||||
CONF_NOTIFY_OVERDUE_ENABLED,
|
||||
CONF_NOTIFY_OVERDUE_INTERVAL,
|
||||
CONF_NOTIFY_SERVICE,
|
||||
CONF_NOTIFY_TRIGGERED_ENABLED,
|
||||
CONF_NOTIFY_TRIGGERED_INTERVAL,
|
||||
CONF_OPERATOR_WRITE_ENABLED,
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_PANEL_TITLE,
|
||||
CONF_QUIET_HOURS_ENABLED,
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_SNOOZE_DURATION_HOURS,
|
||||
DEFAULT_BUDGET_CURRENCY,
|
||||
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
|
||||
DEFAULT_PANEL_ENABLED,
|
||||
DEFAULT_SNOOZE_DURATION_HOURS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
MAX_PANEL_TITLE_LENGTH,
|
||||
TIME_HHMMSS_PATTERN,
|
||||
)
|
||||
from .helpers.i18n import normalize_language
|
||||
from .helpers.notify_targets import build_notify_targets
|
||||
from .helpers.settings_registry import float_range, int_range
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# NumberSelector bounds are pulled from the shared settings registry so the
|
||||
# options-flow forms can't drift from the WS write-handler's range validation.
|
||||
_WARN_MIN, _WARN_MAX = int_range(CONF_DEFAULT_WARNING_DAYS)
|
||||
# The three per-status notify-interval selectors share one bound. That's only
|
||||
# valid while the registry keeps their ranges identical — assert it so a future
|
||||
# divergence fails loudly at load instead of silently using the due-soon bound.
|
||||
_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX = int_range(CONF_NOTIFY_DUE_SOON_INTERVAL)
|
||||
assert (
|
||||
int_range(CONF_NOTIFY_OVERDUE_INTERVAL)
|
||||
== int_range(CONF_NOTIFY_TRIGGERED_INTERVAL)
|
||||
== (_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX)
|
||||
), "notify-interval ranges diverged — give each selector its own registry bound"
|
||||
_MAX_PER_DAY_MIN, _MAX_PER_DAY_MAX = int_range(CONF_MAX_NOTIFICATIONS_PER_DAY)
|
||||
_BUNDLE_MIN, _BUNDLE_MAX = int_range(CONF_NOTIFICATION_BUNDLE_THRESHOLD)
|
||||
_SNOOZE_MIN, _SNOOZE_MAX = int_range(CONF_SNOOZE_DURATION_HOURS)
|
||||
_ALERT_MIN, _ALERT_MAX = int_range(CONF_BUDGET_ALERT_THRESHOLD)
|
||||
_BUDGET_MONTHLY_MIN, _BUDGET_MONTHLY_MAX = float_range(CONF_BUDGET_MONTHLY)
|
||||
_BUDGET_YEARLY_MIN, _BUDGET_YEARLY_MAX = float_range(CONF_BUDGET_YEARLY)
|
||||
|
||||
_VALID_SERVICE_PART = re.compile(r"^[a-z0-9_]+$")
|
||||
# v1.4.6: HH:MM or HH:MM:SS, 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": "🔧 测试通知 — 您的通知设置已生效!",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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(),
|
||||
)
|
||||
Reference in New Issue
Block a user