New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
"""Binary sensor platform for the Maintenance Supporter integration.
Each maintenance task gets a binary_sensor entity that is ON when the task
is overdue or triggered, making it easy to use in HA automations.
Update paths:
1. CoordinatorEntity._handle_coordinator_update (every 5 min) — base mechanism
2. SIGNAL_TASK_RESET dispatcher signal (on complete/skip/reset) — immediate
3. sensor.py trigger callbacks update coordinator data → picked up on next
coordinator refresh automatically
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_OBJECT,
CONF_TASKS,
GLOBAL_UNIQUE_ID,
SIGNAL_TASK_RESET,
MaintenanceStatus,
slugify_object_name,
)
from .coordinator import MaintenanceCoordinator
from .entity.entity_base import MaintenanceEntity
from .helpers.status import compute_status_from_task_dict
if TYPE_CHECKING:
from . import MaintenanceSupporterConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
_PROBLEM_STATUSES = {MaintenanceStatus.OVERDUE, MaintenanceStatus.TRIGGERED}
async def async_setup_entry(
hass: HomeAssistant,
entry: MaintenanceSupporterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensor entities for a maintenance object."""
if entry.unique_id == GLOBAL_UNIQUE_ID:
return
runtime_data = entry.runtime_data
if runtime_data is None or runtime_data.coordinator is None:
_LOGGER.error("No coordinator found for entry %s", entry.entry_id)
return
coordinator = runtime_data.coordinator
tasks = entry.data.get(CONF_TASKS, {})
entities = [MaintenanceBinarySensor(coordinator, task_id) for task_id in tasks]
async_add_entities(entities)
_LOGGER.debug(
"Added %d binary_sensor entities for %s",
len(entities),
entry.title,
)
class MaintenanceBinarySensor(MaintenanceEntity, BinarySensorEntity):
"""Binary sensor that is ON when a maintenance task needs attention.
is_on = True when task status is OVERDUE or TRIGGERED.
device_class = PROBLEM so that ON = bad (needs attention).
"""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_translation_key = "maintenance_task_due"
def __init__(
self,
coordinator: MaintenanceCoordinator,
task_id: str,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, task_id)
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_overdue"
entity_slug = task_data.get("entity_slug")
if entity_slug:
self._attr_name = f"{entity_slug} overdue"
self._attr_translation_placeholders = {
"task_name": task_data.get("name", ""),
}
@property
def is_on(self) -> bool | None:
"""Return True if the task is overdue or triggered."""
task = self._task_data
if not task:
return None
status = task.get("_status", MaintenanceStatus.OK)
return status in _PROBLEM_STATUSES
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional attributes."""
task = self._task_data
if not task:
return {}
return {
"maintenance_status": task.get("_status", MaintenanceStatus.OK),
"days_until_due": task.get("_days_until_due"),
"next_due": task.get("_next_due"),
"parent_object": self._object_data.get("name"),
}
async def async_added_to_hass(self) -> None:
"""Register for task reset signals for immediate updates."""
await super().async_added_to_hass()
signal = SIGNAL_TASK_RESET.format(
entry_id=self.coordinator.entry.entry_id,
task_id=self._task_id,
)
self.async_on_remove(async_dispatcher_connect(self.hass, signal, self._handle_task_reset))
@callback
def _handle_task_reset(self) -> None:
"""Handle task reset (completion/skip/reset).
The sensor entity's handler runs first (registered earlier during
platform setup) and updates coordinator.data with the new _status.
We then re-read coordinator data and write our HA state so the
binary sensor reflects the change immediately, not 5 minutes later.
Even if coordinator data is not yet refreshed, clearing _trigger_active
and recomputing status here ensures correctness regardless of ordering.
"""
if self.coordinator.data is None:
return
tasks = self.coordinator.data.get(CONF_TASKS, {})
task = tasks.get(self._task_id, {})
if not task:
return
# Mirror what sensor._handle_task_reset does: clear trigger state
# and recompute status so we don't depend on execution order.
task["_trigger_active"] = False
task["_trigger_current_value"] = None
new_status = self._compute_live_status(task)
task["_status"] = new_status
# Always write state on reset — the task was explicitly acted upon,
# and attributes like days_until_due / next_due have changed.
self.async_write_ha_state()
@staticmethod
def _compute_live_status(task: dict[str, Any]) -> str:
"""Compute task status from coordinator data dict.
Mirrors MaintenanceTask.status / MaintenanceSensor._compute_live_status.
"""
return compute_status_from_task_dict(task)
@@ -0,0 +1,122 @@
"""Button platform for the Maintenance Supporter integration.
Per-task action buttons (complete / skip / reset) on each object's device. All
presses delegate to the same coordinator methods that the panel, services, and
mobile-notification actions use — one action layer, no duplicated logic (DRY).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_OBJECT,
CONF_TASK_ENABLED,
CONF_TASKS,
GLOBAL_UNIQUE_ID,
slugify_object_name,
)
from .coordinator import MaintenanceCoordinator
from .entity.entity_base import MaintenanceEntity
if TYPE_CHECKING:
from . import MaintenanceSupporterConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
# Per-task action buttons. The English suffix is also used in the entity_id /
# friendly name when the task has a custom entity_slug (mirrors binary_sensor's
# "{slug} overdue" convention).
_TASK_ACTIONS: tuple[tuple[str, str], ...] = (
("complete", "Complete"),
("skip", "Skip"),
("reset", "Reset"),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MaintenanceSupporterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities for a maintenance object (or the global hub)."""
if entry.unique_id == GLOBAL_UNIQUE_ID:
# No global buttons — export belongs to the export_data service, not a
# button entity (a button can't trigger a browser download).
return
runtime_data = entry.runtime_data
if runtime_data is None or runtime_data.coordinator is None:
_LOGGER.error("No coordinator found for entry %s", entry.entry_id)
return
coordinator = runtime_data.coordinator
tasks = entry.data.get(CONF_TASKS, {})
entities = [
MaintenanceActionButton(coordinator, task_id, action, label) for task_id in tasks for action, label in _TASK_ACTIONS
]
async_add_entities(entities)
_LOGGER.debug("Added %d button entities for %s", len(entities), entry.title)
class MaintenanceActionButton(MaintenanceEntity, ButtonEntity):
"""A per-task action button (complete / skip / reset).
Pressing it calls the shared coordinator action method — the same path used
by the maintenance_supporter services and the mobile-notification actions.
"""
def __init__(
self,
coordinator: MaintenanceCoordinator,
task_id: str,
action: str,
label: str,
) -> None:
"""Initialize a per-task action button."""
super().__init__(coordinator, task_id)
self._action = action
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_{action}"
self._attr_translation_key = f"button_{action}"
# Custom entity_slug → stable, language-independent entity_id/name.
entity_slug = task_data.get("entity_slug")
if entity_slug:
self._attr_name = f"{entity_slug} {label}"
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
@property
def available(self) -> bool:
"""Available only while the coordinator is up and the task is enabled."""
if not super().available:
return False
if not self._task_data:
return False
# Archived task → its complete/skip/reset buttons go unavailable (inert).
if self._task_data.get("archived_at") is not None:
return False
task_cfg = self.coordinator.entry.data.get(CONF_TASKS, {}).get(self._task_id, {})
return bool(task_cfg.get(CONF_TASK_ENABLED, True))
async def async_press(self) -> None:
"""Run the action via the shared coordinator method (single source)."""
if not self._task_data:
raise HomeAssistantError(f"Task {self._task_id} no longer exists")
if self._action == "complete":
await self.coordinator.complete_maintenance(self._task_id, notes="Completed from dashboard button")
elif self._action == "skip":
await self.coordinator.skip_maintenance(self._task_id, reason="Skipped from dashboard button")
elif self._action == "reset":
await self.coordinator.reset_maintenance(self._task_id)
@@ -0,0 +1,531 @@
"""Calendar platform for the Maintenance Supporter integration."""
from __future__ import annotations
import logging
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_OBJECT,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceStatus,
ScheduleType,
)
from .helpers.dates import interval_span_days
from .helpers.i18n import normalize_language
from .models.maintenance_task import MaintenanceTask
if TYPE_CHECKING:
from . import MaintenanceSupporterConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
# Status to emoji/prefix mapping
STATUS_PREFIX: dict[str, str] = {
MaintenanceStatus.OK: "🟢",
MaintenanceStatus.DUE_SOON: "🟡",
MaintenanceStatus.OVERDUE: "🔴",
MaintenanceStatus.TRIGGERED: "🔔",
}
# Calendar event translations (not covered by HA translation system)
_CAL_STRINGS: dict[str, dict[str, str]] = {
"de": {
"type": "Typ",
"interval": "Intervall",
"interval_days": "{days} Tage",
"cal_last": "Letzter",
"cal_day": "Tag",
"last_performed": "Zuletzt durchgeführt",
"never": "Nie",
"manually_triggered": "Manuell ausgelöste Wartungsaufgabe",
"sensor_triggered": "Sensor-Trigger ausgelöst für {name}",
"cleaning": "Reinigung",
"inspection": "Inspektion",
"replacement": "Austausch",
"calibration": "Kalibrierung",
"service": "Service",
"reading": "Ablesung",
"custom": "Benutzerdefiniert",
},
"nl": {
"type": "Type",
"interval": "Interval",
"interval_days": "{days} dagen",
"cal_last": "Laatste",
"cal_day": "Dag",
"last_performed": "Laatst uitgevoerd",
"never": "Nooit",
"manually_triggered": "Handmatig geactiveerde onderhoudstaak",
"sensor_triggered": "Sensortrigger geactiveerd voor {name}",
"cleaning": "Reiniging",
"inspection": "Inspectie",
"replacement": "Vervanging",
"calibration": "Kalibratie",
"service": "Service",
"reading": "Aflezing",
"custom": "Aangepast",
},
"fr": {
"type": "Type",
"interval": "Intervalle",
"interval_days": "{days} jours",
"cal_last": "Dernier",
"cal_day": "Jour",
"last_performed": "Dernière exécution",
"never": "Jamais",
"manually_triggered": "Tâche de maintenance déclenchée manuellement",
"sensor_triggered": "Déclencheur capteur activé pour {name}",
"cleaning": "Nettoyage",
"inspection": "Inspection",
"replacement": "Remplacement",
"calibration": "Calibration",
"service": "Service",
"reading": "Relevé",
"custom": "Personnalisé",
},
"it": {
"type": "Tipo",
"interval": "Intervallo",
"interval_days": "{days} giorni",
"cal_last": "Ultimo",
"cal_day": "Giorno",
"last_performed": "Ultima esecuzione",
"never": "Mai",
"manually_triggered": "Attività di manutenzione attivata manualmente",
"sensor_triggered": "Trigger sensore attivato per {name}",
"cleaning": "Pulizia",
"inspection": "Ispezione",
"replacement": "Sostituzione",
"calibration": "Calibrazione",
"service": "Servizio",
"reading": "Lettura",
"custom": "Personalizzato",
},
"es": {
"type": "Tipo",
"interval": "Intervalo",
"interval_days": "{days} días",
"cal_last": "Último",
"cal_day": "Día",
"last_performed": "Última ejecución",
"never": "Nunca",
"manually_triggered": "Tarea de mantenimiento activada manualmente",
"sensor_triggered": "Disparador de sensor activado para {name}",
"cleaning": "Limpieza",
"inspection": "Inspección",
"replacement": "Reemplazo",
"calibration": "Calibración",
"service": "Servicio",
"reading": "Lectura",
"custom": "Personalizado",
},
"en": {
"type": "Type",
"interval": "Interval",
"interval_days": "{days} days",
"cal_last": "Last",
"cal_day": "Day",
"last_performed": "Last performed",
"never": "Never",
"manually_triggered": "Manually triggered maintenance task",
"sensor_triggered": "Sensor trigger activated for {name}",
"cleaning": "Cleaning",
"inspection": "Inspection",
"replacement": "Replacement",
"calibration": "Calibration",
"service": "Service",
"reading": "Reading",
"custom": "Custom",
},
"ru": {
"type": "Тип",
"interval": "Интервал",
"interval_days": "{days} дней",
"cal_last": "Последний",
"cal_day": "День",
"last_performed": "Последнее выполнение",
"never": "Никогда",
"manually_triggered": "Задача обслуживания запущена вручную",
"sensor_triggered": "Триггер датчика активирован для {name}",
"cleaning": "Очистка",
"inspection": "Осмотр",
"replacement": "Замена",
"calibration": "Калибровка",
"service": "Обслуживание",
"reading": "Показания",
"custom": "Пользовательский",
},
"uk": {
"type": "Тип",
"interval": "Інтервал",
"interval_days": "{days} днів",
"cal_last": "Останній",
"cal_day": "День",
"last_performed": "Останнє виконання",
"never": "Ніколи",
"manually_triggered": "Завдання обслуговування запущено вручну",
"sensor_triggered": "Спрацював сенсорний тригер для {name}",
"cleaning": "Очищення",
"inspection": "Огляд",
"replacement": "Заміна",
"calibration": "Калібрування",
"service": "Сервіс",
"reading": "Показання",
"custom": "Власний",
},
"pt": {
"type": "Tipo",
"interval": "Intervalo",
"interval_days": "{days} dias",
"cal_last": "Último",
"cal_day": "Dia",
"last_performed": "Última execução",
"never": "Nunca",
"manually_triggered": "Tarefa de manutenção acionada manualmente",
"sensor_triggered": "Gatilho do sensor ativado para {name}",
"cleaning": "Limpeza",
"inspection": "Inspeção",
"replacement": "Substituição",
"calibration": "Calibração",
"service": "Serviço",
"reading": "Leitura",
"custom": "Personalizado",
},
"zh": {
"type": "类型",
"interval": "间隔",
"interval_days": "{days}",
"cal_last": "最后",
"cal_day": "",
"last_performed": "最后执行时间",
"never": "从未",
"manually_triggered": "手动触发维护任务",
"sensor_triggered": "{name} 的传感器触发器已激活",
"cleaning": "清洁",
"inspection": "检查",
"replacement": "更换",
"calibration": "校准",
"service": "服务",
"reading": "读数",
"custom": "自定义",
},
}
def _cal_t(key: str, lang: str, **kwargs: str) -> str:
"""Get calendar translation string."""
strings = _CAL_STRINGS.get(lang, _CAL_STRINGS["en"])
text = strings.get(key, _CAL_STRINGS["en"].get(key, key))
if kwargs:
text = text.format(**kwargs)
return text
def _recurrence_text(task: MaintenanceTask, lang: str) -> str:
"""Localized recurrence label for the calendar event description.
Covers the calendar kinds (weekdays / nth_weekday / day_of_month) using
babel weekday names so "1. Samstag" reads localized; falls back to the
real day-span for interval/legacy tasks (so a 3-month task reads "~90
days", not "3 days").
"""
raw = task.schedule_raw if isinstance(task.schedule_raw, dict) else None
kind = raw.get("kind") if raw else None
if raw is not None and kind in ("weekdays", "nth_weekday", "day_of_month"):
loc = lang or "en"
try:
# babel (HA-provided) gives locale-correct weekday names; degrade
# gracefully (no label) if it is somehow absent, never crash.
from babel.dates import get_day_names
if kind == "weekdays":
names = get_day_names("abbreviated", locale=loc)
days = [d for d in raw.get("weekdays") or [] if isinstance(d, int) and 0 <= d <= 6]
return " & ".join(names[d] for d in days)
if kind == "nth_weekday":
wd, nth = raw.get("weekday"), raw.get("nth")
if not isinstance(wd, int) or not isinstance(nth, int):
return ""
name = get_day_names("wide", locale=loc)[wd]
ordinal = _cal_t("cal_last", lang) if nth == -1 else f"{nth}."
return f"{ordinal} {name}"
# day_of_month
day = raw.get("day")
return f"{_cal_t('cal_day', lang)} {day}" if isinstance(day, int) else ""
except (ImportError, KeyError, LookupError, ValueError):
return ""
# interval / legacy flat → real day-span
return (
_cal_t("interval_days", lang, days=str(interval_span_days(task.interval_days, task.interval_unit)))
if task.interval_days
else ""
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MaintenanceSupporterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up calendar entity."""
# Only create calendar for the global entry
if entry.unique_id != GLOBAL_UNIQUE_ID:
# Register this object's coordinator with existing calendar
runtime_data = entry.runtime_data
if runtime_data and runtime_data.coordinator:
calendar = hass.data.get(DOMAIN, {}).get("_calendar_entity")
if calendar:
runtime_data.coordinator.register_calendar_entity(calendar)
return
calendar = MaintenanceCalendar(hass)
hass.data.setdefault(DOMAIN, {})["_calendar_entity"] = calendar
async_add_entities([calendar])
# Register calendar with existing coordinators
for other_entry in hass.config_entries.async_entries(DOMAIN):
if other_entry.unique_id == GLOBAL_UNIQUE_ID:
continue
other_data = getattr(other_entry, "runtime_data", None)
if other_data and hasattr(other_data, "coordinator") and other_data.coordinator:
other_data.coordinator.register_calendar_entity(calendar)
_LOGGER.debug("Maintenance calendar entity created")
class MaintenanceCalendar(CalendarEntity):
"""Calendar entity aggregating all maintenance tasks."""
_attr_name = "Maintenance Schedule"
_attr_unique_id = "maintenance_supporter_calendar"
_attr_translation_key = "maintenance_schedule"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the calendar."""
self._hass = hass
self._cached_next_event: CalendarEvent | None = None
self._cache_time: datetime | None = None
def invalidate_cache(self) -> None:
"""Invalidate the cached next event (called by coordinators on update)."""
self._cache_time = None
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event (cached for up to 1 hour)."""
now = dt_util.now()
if self._cache_time is not None and (now - self._cache_time).total_seconds() < 3600:
return self._cached_next_event
events = self._get_all_events(now, now + timedelta(days=365))
if events:
# Events mix all-day (date) and timed (datetime) starts since v1.0.41.
# Normalise to datetime for comparison so sort doesn't TypeError.
def _key(e: CalendarEvent) -> datetime:
s = e.start
if isinstance(s, datetime):
return s if s.tzinfo else s.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return datetime.combine(s, time.min, tzinfo=dt_util.DEFAULT_TIME_ZONE)
events.sort(key=_key)
self._cached_next_event = events[0]
else:
self._cached_next_event = None
self._cache_time = now
return self._cached_next_event
async def async_get_events(
self,
hass: HomeAssistant,
start_date: datetime,
end_date: datetime,
) -> list[CalendarEvent]:
"""Return calendar events within the date range."""
return self._get_all_events(start_date, end_date)
def _get_all_events(
self,
start_date: datetime | date,
end_date: datetime | date,
) -> list[CalendarEvent]:
"""Collect events from all maintenance objects."""
events: list[CalendarEvent] = []
# Convert to dates for comparison
if isinstance(start_date, datetime):
start_d = start_date.date()
else:
start_d = start_date
if isinstance(end_date, datetime):
end_d = end_date.date()
else:
end_d = end_date
entries = self._hass.config_entries.async_entries(DOMAIN)
for entry in entries:
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
obj_data = entry.data.get(CONF_OBJECT, {})
obj_name = obj_data.get("name", "Unknown")
# v2.10.0: an archived object contributes no calendar events (its
# tasks are cascade-archived too, but skip the whole entry up front).
if obj_data.get("archived_at") is not None:
continue
# v2.20 (N3): a seasonally paused object's schedules are frozen —
# projecting due dates would show events that won't fire.
if obj_data.get("paused_at") is not None:
continue
# Use live coordinator data (has trigger state) if available,
# fall back to config entry data
runtime_data = getattr(entry, "runtime_data", None)
if (
runtime_data
and hasattr(runtime_data, "coordinator")
and runtime_data.coordinator
and runtime_data.coordinator.data
):
live_tasks = runtime_data.coordinator.data.get(CONF_TASKS, {})
else:
live_tasks = {}
# Merge static (ConfigEntry) + dynamic (Store) task data
store = getattr(runtime_data, "store", None) if runtime_data else None
static_tasks = entry.data.get(CONF_TASKS, {})
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
for task_id, task_dict in tasks_data.items():
task = MaintenanceTask.from_dict(task_dict)
if not task.enabled:
continue
# Archived task → no calendar entries (inert).
if task.archived_at is not None:
continue
# Inject live trigger state from coordinator
live = live_tasks.get(task_id, {})
if live.get("_trigger_active", False):
task._trigger_active = True
if live.get("_trigger_current_value") is not None:
task._trigger_current_value = live["_trigger_current_value"]
event = self._create_event_for_task(task, obj_name, start_d, end_d)
if event:
events.append(event)
return events
@property
def _lang(self) -> str:
"""Get the HA UI language as a 2-letter table key."""
return normalize_language(self._hass)
def _create_event_for_task(
self,
task: MaintenanceTask,
object_name: str,
start_d: date,
end_d: date,
) -> CalendarEvent | None:
"""Create a calendar event for a task if within range."""
lang = self._lang
if task.schedule_type == ScheduleType.MANUAL:
# Manual tasks only show if triggered
if not task._trigger_active:
return None
# Show as event for today
today = dt_util.now().date()
if start_d <= today <= end_d:
return CalendarEvent(
summary=f"{STATUS_PREFIX.get(MaintenanceStatus.TRIGGERED, '🔔')} {task.name} ({object_name})",
start=today,
end=today + timedelta(days=1),
description=_cal_t("manually_triggered", lang),
)
return None
# Time-based or sensor-based with interval
next_due = task.next_due
if next_due is None:
if task._trigger_active:
# Sensor triggered without fixed date: show today
today = dt_util.now().date()
if start_d <= today <= end_d:
return CalendarEvent(
summary=f"{STATUS_PREFIX.get(MaintenanceStatus.TRIGGERED, '🔔')} {task.name} ({object_name})",
start=today,
end=today + timedelta(days=1),
description=_cal_t("sensor_triggered", lang, name=task.name),
)
return None
# Check if next_due is in range
if next_due < start_d or next_due > end_d:
return None
status = task.status
prefix = STATUS_PREFIX.get(status, "")
# Build translated description
type_translated = _cal_t(task.type, lang) if task.type else task.type
# Show the real day-span so a 3-month task reads "~90 days", not "3 days"
# (the calendar event itself is already placed at the unit-aware next_due).
interval_text = _recurrence_text(task, lang)
last_perf = str(task.last_performed) if task.last_performed else _cal_t("never", lang)
# Build event window. Default: all-day. When schedule_time is set
# (and the global advanced flag is on), render as a 30-min timed
# event at HH:MM in HA's configured TZ — calendar apps can then
# set proper alarms instead of the generic "all-day" reminder.
start: date | datetime = next_due
end: date | datetime = next_due + timedelta(days=1)
if task.schedule_time and self._is_schedule_time_feature_enabled():
try:
hh, mm = task.schedule_time.split(":", 1)
start_dt = datetime.combine(
next_due,
time(int(hh), int(mm)),
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
start = start_dt
end = start_dt + timedelta(minutes=30)
except (ValueError, TypeError):
pass # malformed schedule_time → fall back to all-day
return CalendarEvent(
summary=f"{prefix} {task.name} ({object_name})",
start=start,
end=end,
description=(
f"{_cal_t('type', lang)}: {type_translated}\n"
f"{_cal_t('interval', lang)}: {interval_text}\n"
f"{_cal_t('last_performed', lang)}: {last_perf}"
),
)
def _is_schedule_time_feature_enabled(self) -> bool:
"""Lookup the global advanced flag — same approach as coordinator."""
for ce in self._hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
opts = ce.options or ce.data
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
return False
@@ -0,0 +1,46 @@
"""Purpose-specific conditions for the HA automation editor (2026.7+).
Companion to :mod:`.trigger` — condition building blocks like *"A maintenance
task is overdue"* for the intent-based automation UI. State-based on the
per-task ENUM sensors; see trigger.py for the targeting rationale and the
old-core import guard.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from .const import MaintenanceStatus
if TYPE_CHECKING:
from homeassistant.helpers.condition import Condition
try: # pragma: no cover - exercised only on cores without the framework
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import make_entity_state_condition
except ImportError: # pragma: no cover - only on cores without the framework
CONDITIONS: dict[str, type[Condition]] = {}
else:
_TASK_SENSOR_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ENUM),
}
CONDITIONS = {
"task_is_overdue": make_entity_state_condition(_TASK_SENSOR_SPECS, MaintenanceStatus.OVERDUE),
"task_is_due_soon": make_entity_state_condition(_TASK_SENSOR_SPECS, MaintenanceStatus.DUE_SOON),
"task_is_triggered": make_entity_state_condition(_TASK_SENSOR_SPECS, MaintenanceStatus.TRIGGERED),
# "Needs attention" = the two states that demand action now.
"task_needs_attention": make_entity_state_condition(
_TASK_SENSOR_SPECS,
{str(MaintenanceStatus.OVERDUE), str(MaintenanceStatus.TRIGGERED)},
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for maintenance tasks."""
return CONDITIONS
@@ -0,0 +1,23 @@
.condition_common: &condition_common
target:
entity:
- domain: sensor
integration: maintenance_supporter
device_class: enum
fields:
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
required: true
default: 00:00:00
selector:
duration:
task_is_overdue: *condition_common
task_is_due_soon: *condition_common
task_is_triggered: *condition_common
task_needs_attention: *condition_common
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
"""Shared helpers for config flow and options flow threshold suggestions."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from .const import CONF_TASK_INTERVAL_UNIT
from .helpers.dates import INTERVAL_UNITS
from .helpers.entity_analyzer import EntityAnalyzer
from .helpers.schedule import (
KIND_DAY_OF_MONTH,
KIND_NTH_WEEKDAY,
KIND_WEEKDAYS,
Schedule,
)
from .helpers.threshold_calculator import ThresholdCalculator, ThresholdSuggestions
_LOGGER = logging.getLogger(__name__)
# Calendar recurrence kinds offered in the config + options flows (Phase 4).
# Hardcoded English labels for the weekday/occurrence sub-options keep the
# config-flow i18n surface small; the kind names are translated via strings.json.
CALENDAR_KIND_VALUES = (KIND_WEEKDAYS, KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH)
_WEEKDAY_LABELS = (
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
)
_NTH_OPTIONS = (
("1", "1st"),
("2", "2nd"),
("3", "3rd"),
("4", "4th"),
("5", "5th"),
("-1", "Last"),
)
def calendar_schema(kind: str, current: dict[str, Any] | None = None) -> vol.Schema:
"""Voluptuous schema for a calendar kind's fields (weekdays / nth_weekday /
day_of_month). Shared by the config + options flows (DRY). Callers extend it
with their own warning_days / last_performed / go_back fields."""
cur = current or {}
weekday_opts = [selector.SelectOptionDict(value=str(i), label=lbl) for i, lbl in enumerate(_WEEKDAY_LABELS)]
fields: dict[Any, Any] = {}
if kind == KIND_WEEKDAYS:
fields[vol.Required("weekdays", default=cur.get("weekdays", []))] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=weekday_opts,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
)
)
elif kind == KIND_NTH_WEEKDAY:
fields[vol.Required("nth", default=cur.get("nth", "1"))] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[selector.SelectOptionDict(value=v, label=lbl) for v, lbl in _NTH_OPTIONS],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
fields[vol.Required("weekday", default=cur.get("weekday", "5"))] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=weekday_opts,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
elif kind == KIND_DAY_OF_MONTH:
fields[vol.Required("day", default=cur.get("day", 1))] = selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=31, step=1, mode=selector.NumberSelectorMode.BOX)
)
# (#83) end-of-month options: "last day" overrides the day number;
# "business" rolls a weekend date back to Friday.
fields[vol.Optional("last_day", default=cur.get("last_day", False))] = selector.BooleanSelector()
fields[vol.Optional("business", default=cur.get("business", False))] = selector.BooleanSelector()
# (#83) ±N-day shift of the computed occurrence, on every calendar kind
# ("two days before the last working day" = last_day + business + offset -2).
fields[vol.Optional("offset", default=cur.get("offset", 0))] = selector.NumberSelector(
selector.NumberSelectorConfig(min=-15, max=15, step=1, mode=selector.NumberSelectorMode.BOX)
)
return vol.Schema(fields)
def schedule_from_calendar_input(kind: str, user_input: dict[str, Any]) -> dict[str, Any] | None:
"""Build the nested `schedule` dict from a calendar step's user_input."""
def _with_offset(schedule: dict[str, Any]) -> dict[str, Any]:
offset = int(user_input.get("offset", 0) or 0)
if offset:
schedule["offset"] = offset
return schedule
if kind == KIND_WEEKDAYS:
days = sorted(int(d) for d in user_input.get("weekdays", []))
return _with_offset({"kind": KIND_WEEKDAYS, "weekdays": days}) if days else None
if kind == KIND_NTH_WEEKDAY:
return _with_offset(
{
"kind": KIND_NTH_WEEKDAY,
"nth": int(user_input["nth"]),
"weekday": int(user_input["weekday"]),
}
)
if kind == KIND_DAY_OF_MONTH:
# (#83) "last day" wins over the day number; business rolls back weekends.
day = -1 if user_input.get("last_day") else int(user_input.get("day", 1))
schedule: dict[str, Any] = {"kind": KIND_DAY_OF_MONTH, "day": day}
if user_input.get("business"):
schedule["business"] = True
return _with_offset(schedule)
return None
def calendar_current(task: dict[str, Any]) -> dict[str, Any]:
"""Current calendar-field values from a task's nested schedule, in the shape
`calendar_schema` defaults expect (selector values are strings)."""
s = Schedule.parse(task)
return {
"weekdays": [str(d) for d in s.weekdays],
"nth": str(s.nth) if s.nth is not None else "1",
"weekday": str(s.weekday) if s.weekday is not None else "5",
"day": s.day if (s.day or 0) >= 1 else 1,
"last_day": s.day == -1,
"business": s.business,
"offset": s.offset_days,
}
def interval_unit_selector() -> selector.SelectSelector:
"""Shared days/weeks/months/years dropdown for the interval unit.
DRY single source for the time-based interval AND the sensor safety-interval
steps across the config + options flows (previously duplicated 7×). Options
come from the canonical ``INTERVAL_UNITS``; ``translation_key`` localizes them
via ``selector.interval_unit.options`` in strings.json.
"""
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(INTERVAL_UNITS),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="interval_unit",
)
)
def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None:
"""Persist ``interval_unit`` from a flow step into ``target`` only when it
differs from the implicit default ``days`` (keeps stored task dicts minimal).
DRY: replaces the ``if unit != "days"`` block duplicated across flow steps.
"""
unit = user_input.get(CONF_TASK_INTERVAL_UNIT, "days")
if unit != "days":
target[CONF_TASK_INTERVAL_UNIT] = unit
async def async_get_threshold_suggestions(
hass: HomeAssistant,
trigger_entity_id: str | None,
current_task: dict[str, Any],
) -> ThresholdSuggestions:
"""Get threshold suggestions using EntityAnalyzer and ThresholdCalculator."""
if not trigger_entity_id:
return ThresholdSuggestions()
try:
analyzer = EntityAnalyzer(hass)
analysis = await analyzer.async_analyze_entity(trigger_entity_id)
attribute = current_task.get("trigger_config", {}).get("attribute")
calculator = ThresholdCalculator(hass)
return await calculator.async_calculate_suggestions(trigger_entity_id, attribute, analysis)
except (HomeAssistantError, ValueError, TypeError, KeyError):
_LOGGER.debug(
"Failed to get threshold suggestions for %s",
trigger_entity_id,
exc_info=True,
)
return ThresholdSuggestions()
def format_threshold_placeholders(
trigger_entity_id: str | None,
attribute: str | None,
suggestions: ThresholdSuggestions,
) -> dict[str, str]:
"""Format description placeholders from threshold suggestions."""
return {
"entity_id": trigger_entity_id or "",
"attribute": attribute or "state",
"current_value": str(suggestions.current_value) if suggestions.current_value is not None else "",
"unit": suggestions.unit,
"average": f"{suggestions.average:.1f}" if suggestions.average is not None else "N/A",
"minimum": f"{suggestions.minimum:.1f}" if suggestions.minimum is not None else "N/A",
"maximum": f"{suggestions.maximum:.1f}" if suggestions.maximum is not None else "N/A",
"suggested_above": f"{suggestions.suggested_above:.1f}" if suggestions.suggested_above is not None else "",
"suggested_below": f"{suggestions.suggested_below:.1f}" if suggestions.suggested_below is not None else "",
"data_period": str(suggestions.data_period_days) if suggestions.data_period_days > 0 else "0",
"trend": suggestions.trend or "",
}
@@ -0,0 +1,13 @@
"""Options flows for the Maintenance Supporter integration (re-export shim).
Actual implementations split for maintainability:
- config_flow_options_global.py: GlobalOptionsFlow (global settings, notifications, budget, groups)
- config_flow_options_task.py: MaintenanceOptionsFlow (per-object task CRUD, trigger config)
"""
from __future__ import annotations
from .config_flow_options_global import GlobalOptionsFlow
from .config_flow_options_task import MaintenanceOptionsFlow
__all__ = ["GlobalOptionsFlow", "MaintenanceOptionsFlow"]
@@ -0,0 +1,835 @@
"""Global options flow for the Maintenance Supporter integration.
Contains GlobalOptionsFlow: menu-based global settings (notifications, budget, groups).
Split from config_flow_options.py for better maintainability.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from uuid import uuid4
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector
from .const import (
BUDGET_CURRENCIES,
CONF_ACTION_COMPLETE_ENABLED,
CONF_ACTION_SKIP_ENABLED,
CONF_ACTION_SNOOZE_ENABLED,
CONF_ADMIN_PANEL_USER_IDS,
CONF_ADVANCED_ADAPTIVE,
CONF_ADVANCED_BUDGET,
CONF_ADVANCED_CHECKLISTS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_DEFAULT_WARNING_DAYS,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
CONF_OPERATOR_WRITE_ENABLED,
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_SNOOZE_DURATION_HOURS,
DEFAULT_BUDGET_CURRENCY,
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
DEFAULT_PANEL_ENABLED,
DEFAULT_SNOOZE_DURATION_HOURS,
DEFAULT_WARNING_DAYS,
MAX_PANEL_TITLE_LENGTH,
TIME_HHMMSS_PATTERN,
)
from .helpers.i18n import normalize_language
from .helpers.notify_targets import build_notify_targets
from .helpers.settings_registry import float_range, int_range
_LOGGER = logging.getLogger(__name__)
# NumberSelector bounds are pulled from the shared settings registry so the
# options-flow forms can't drift from the WS write-handler's range validation.
_WARN_MIN, _WARN_MAX = int_range(CONF_DEFAULT_WARNING_DAYS)
# The three per-status notify-interval selectors share one bound. That's only
# valid while the registry keeps their ranges identical — assert it so a future
# divergence fails loudly at load instead of silently using the due-soon bound.
_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX = int_range(CONF_NOTIFY_DUE_SOON_INTERVAL)
assert (
int_range(CONF_NOTIFY_OVERDUE_INTERVAL)
== int_range(CONF_NOTIFY_TRIGGERED_INTERVAL)
== (_NOTIFY_INTERVAL_MIN, _NOTIFY_INTERVAL_MAX)
), "notify-interval ranges diverged — give each selector its own registry bound"
_MAX_PER_DAY_MIN, _MAX_PER_DAY_MAX = int_range(CONF_MAX_NOTIFICATIONS_PER_DAY)
_BUNDLE_MIN, _BUNDLE_MAX = int_range(CONF_NOTIFICATION_BUNDLE_THRESHOLD)
_SNOOZE_MIN, _SNOOZE_MAX = int_range(CONF_SNOOZE_DURATION_HOURS)
_ALERT_MIN, _ALERT_MAX = int_range(CONF_BUDGET_ALERT_THRESHOLD)
_BUDGET_MONTHLY_MIN, _BUDGET_MONTHLY_MAX = float_range(CONF_BUDGET_MONTHLY)
_BUDGET_YEARLY_MIN, _BUDGET_YEARLY_MAX = float_range(CONF_BUDGET_YEARLY)
_VALID_SERVICE_PART = re.compile(r"^[a-z0-9_]+$")
# v1.4.6: HH:MM or HH:MM:SS, 023 hours, 059 minutes/seconds. Shared with the
# WS handler via const.TIME_HHMMSS_PATTERN so the two can't diverge.
_VALID_TIME_PATTERN = TIME_HHMMSS_PATTERN
def _safe_time(value: Any, fallback: str) -> str:
"""Return ``value`` if it parses as HH:MM[:SS], else the ``fallback``.
HA's `TimeSelector` rejects empty strings, `None`, and non-time strings as
"Invalid time" — and that error then blocks the entire form save, even when
the user wasn't editing the time field. Coerce the form's *default* to a
valid time so the user can still hit Save.
"""
if isinstance(value, str) and _VALID_TIME_PATTERN.match(value):
return value
return fallback
def validate_notify_service(raw: str, hass: HomeAssistant | None = None) -> tuple[str, str | None]:
"""Normalize and validate a notify service string.
Returns (normalized_value, error_key | None).
"""
value = raw.strip()
if not value:
return ("", None)
# Auto-fix: prepend "notify." if missing
if "." not in value:
value = f"notify.{value}"
parts = value.split(".")
if len(parts) != 2 or parts[0] != "notify" or not parts[1] or not _VALID_SERVICE_PART.match(parts[1]):
return (value, "invalid_notify_service")
# Check service existence (only when hass available, i.e. options flow)
if hass is not None and not hass.services.has_service(parts[0], parts[1]):
return (value, "notify_service_not_found")
return (value, None)
_TEST_NOTIFICATION_RESULTS: dict[str, dict[str, str]] = {
"de": {
"success": "✅ Testbenachrichtigung gesendet — Ihr Dienst funktioniert. Wenn ein bestimmtes Gerät nichts bekommt, prüfen Sie dieses Gerät in Ihrer Notify-Gruppe (und das HA-Protokoll).",
"no_service": "⚠️ Kein Benachrichtigungsdienst konfiguriert. Bitte zuerst unter Allgemeine Einstellungen einen Dienst einrichten.",
"invalid_service": "❌ Das Format des Benachrichtigungsdienstes ist ungültig. Verwenden Sie 'notify.dienstname'.",
"failed": "❌ Testbenachrichtigung konnte nicht gesendet werden. Bitte prüfen Sie Ihre Konfiguration.",
"push_message": "🔧 Testbenachrichtigung — Ihre Benachrichtigungseinrichtung funktioniert!",
},
"nl": {
"success": "✅ Testmelding verzonden — uw service werkt. Krijgt een specifiek apparaat niets, controleer dat apparaat in uw notify-groep (en het HA-logboek).",
"no_service": "⚠️ Geen meldingsservice geconfigureerd. Stel eerst een service in onder Algemene instellingen.",
"invalid_service": "❌ Het formaat van de meldingsservice is ongeldig. Gebruik 'notify.servicenaam'.",
"failed": "❌ Testmelding kon niet worden verzonden. Controleer uw configuratie.",
"push_message": "🔧 Testmelding — uw meldingsinstellingen werken!",
},
"fr": {
"success": "✅ Notification de test envoyée — votre service fonctionne. Si un appareil précis ne reçoit rien, vérifiez-le dans votre groupe notify (et les journaux HA).",
"no_service": "⚠️ Aucun service de notification configuré. Veuillez d'abord configurer un service dans les paramètres généraux.",
"invalid_service": "❌ Le format du service de notification est invalide. Utilisez 'notify.nom_du_service'.",
"failed": "❌ Impossible d'envoyer la notification de test. Veuillez vérifier votre configuration.",
"push_message": "🔧 Notification de test — votre configuration de notifications fonctionne !",
},
"it": {
"success": "✅ Notifica di test inviata — il servizio funziona. Se un dispositivo specifico non riceve nulla, controllalo nel tuo gruppo notify (e nei log di HA).",
"no_service": "⚠️ Nessun servizio di notifica configurato. Configura prima un servizio nelle impostazioni generali.",
"invalid_service": "❌ Il formato del servizio di notifica non è valido. Usa 'notify.nome_servizio'.",
"failed": "❌ Impossibile inviare la notifica di test. Verifica la tua configurazione.",
"push_message": "🔧 Notifica di test — la configurazione delle notifiche funziona!",
},
"es": {
"success": "✅ Notificación de prueba enviada — tu servicio funciona. Si un dispositivo concreto no recibe nada, revísalo en tu grupo notify (y los registros de HA).",
"no_service": "⚠️ No hay servicio de notificación configurado. Configure primero un servicio en la configuración general.",
"invalid_service": "❌ El formato del servicio de notificación no es válido. Use 'notify.nombre_servicio'.",
"failed": "❌ No se pudo enviar la notificación de prueba. Verifique su configuración.",
"push_message": "🔧 Notificación de prueba — ¡su configuración de notificaciones funciona!",
},
"en": {
"success": "✅ Test notification sent — your service works. If a specific device gets nothing, check that device inside your notify group (and Home Assistant's logs).",
"no_service": "⚠️ No notification service configured. Please configure a service in General Settings first.",
"invalid_service": "❌ The notification service format is invalid. Use 'notify.service_name'.",
"failed": "❌ Failed to send the test notification. Please verify your service configuration.",
"push_message": "🔧 Test notification — your notification setup is working!",
},
"ru": {
"success": "✅ Тестовое уведомление отправлено — сервис работает. Если конкретное устройство ничего не получает, проверьте его в вашей notify-группе (и в журналах HA).",
"no_service": "⚠️ Сервис уведомлений не настроен. Сначала настройте сервис в Основных настройках.",
"invalid_service": "❌ Неверный формат сервиса уведомлений. Используйте 'notify.имя_сервиса'.",
"failed": "❌ Не удалось отправить тестовое уведомление. Проверьте настройки сервиса.",
"push_message": "🔧 Тестовое уведомление — ваша система уведомлений работает!",
},
"uk": {
"success": "✅ Тестове сповіщення надіслано — служба працює. Якщо певний пристрій нічого не отримує, перевірте його у вашій notify-групі (та в журналах HA).",
"no_service": "⚠️ Службу сповіщень не налаштовано. Спочатку вкажіть службу в загальних налаштуваннях.",
"invalid_service": "❌ Невірний формат служби сповіщень. Використовуйте 'notify.service_name'.",
"failed": "❌ Не вдалося надіслати тестове сповіщення. Перевірте конфігурацію служби.",
"push_message": "🔧 Тестове сповіщення — ваші сповіщення працюють!",
},
"pt": {
"success": "✅ Notificação de teste enviada — o seu serviço funciona. Se um dispositivo específico não receber nada, verifique-o no seu grupo notify (e nos registos do HA).",
"no_service": "⚠️ Serviço de notificação não configurado. Configure primeiro nas Configurações Gerais.",
"invalid_service": "❌ Formato inválido do serviço de notificação. Use 'notify.nome_do_servico'.",
"failed": "❌ Falha ao enviar a notificação de teste. Verifique a configuração do serviço.",
"push_message": "🔧 Notificação de teste — as suas notificações estão a funcionar!",
},
"zh": {
"success": "✅ 测试通知已发送 — 您的服务正常。如果某个设备未收到,请在您的 notify 群组中检查该设备(以及 HA 日志)。",
"no_service": "⚠️ 未配置通知服务。请先在“通用设置”中配置服务。",
"invalid_service": "❌ 通知服务格式无效。请使用 'notify.服务名称' 格式。",
"failed": "❌ 测试通知发送失败。请验证您的服务配置。",
"push_message": "🔧 测试通知 — 您的通知设置已生效!",
},
}
def _get_test_result_text(hass: HomeAssistant, key: str) -> str:
"""Get localized test notification result text."""
lang = normalize_language(hass)
texts = _TEST_NOTIFICATION_RESULTS.get(lang, _TEST_NOTIFICATION_RESULTS["en"])
return texts.get(key, texts.get("failed", key))
async def send_test_notification(hass: HomeAssistant, options: dict[str, Any]) -> str:
"""Send a test notification using the configured notify service.
Returns a result key ("success", "no_service", "invalid_service", "failed")
that callers map to localized text. Action buttons are included whenever
the corresponding action-feature toggles are enabled, so the rendered
notification matches the real layout users see for actual tasks.
"""
notify_service = str(options.get(CONF_NOTIFY_SERVICE, ""))
if not notify_service:
return "no_service"
# Format-only validation — existence is left to the async_call below so
# notify services registered lazily (e.g. mobile_app_*) still test cleanly.
normalized, error = validate_notify_service(notify_service)
if error:
return "invalid_service"
try:
from .helpers.notification_manager import async_dispatch_notify
push_msg = _get_test_result_text(hass, "push_message")
service_data: dict[str, Any] = {
"title": "Maintenance Supporter",
"message": push_msg,
}
actions_enabled = options.get(CONF_ACTION_COMPLETE_ENABLED, False)
skip_enabled = options.get(CONF_ACTION_SKIP_ENABLED, False)
snooze_enabled = options.get(CONF_ACTION_SNOOZE_ENABLED, False)
if actions_enabled or skip_enabled or snooze_enabled:
test_actions: list[dict[str, str]] = []
if actions_enabled:
test_actions.append({"action": "MS_TEST_COMPLETE", "title": "\u2705 Complete"})
if skip_enabled:
test_actions.append({"action": "MS_TEST_SKIP", "title": "\u23ed\ufe0f Skip"})
if snooze_enabled:
test_actions.append({"action": "MS_TEST_SNOOZE", "title": "\U0001f4a4 Snooze"})
service_data["data"] = {"actions": test_actions}
# Dual-path: legacy notify service OR notify entity (send_message).
if not await async_dispatch_notify(hass, normalized, service_data, blocking=True):
return "failed"
return "success"
except Exception: # noqa: BLE001 - any failure mode reports "failed" to the UI
_LOGGER.debug("Test notification failed for %s", notify_service, exc_info=True)
return "failed"
class GlobalOptionsFlow(OptionsFlow):
"""Handle global options with menu-based navigation."""
@property
def _current(self) -> dict[str, Any]:
"""Get current options."""
return dict(self.config_entry.options or self.config_entry.data)
def _save_and_return(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Merge user input into options and return to the menu."""
merged = self._current
merged.update(user_input)
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
def _menu_options(self) -> list[str]:
"""Build dynamic menu options."""
current = self._current
options = ["general_settings", "advanced_features", "panel_access"]
if current.get(CONF_ADVANCED_BUDGET, False):
options.append("budget_settings")
if current.get(CONF_ADVANCED_GROUPS, False):
options.append("manage_groups")
if current.get(CONF_NOTIFICATIONS_ENABLED, False):
options.extend(
[
"notification_settings",
"notification_actions",
"test_notification",
]
)
options.append("done")
return options
# --- Menu ---
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Show global options menu."""
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
async def async_step_global_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle menu selection redirect."""
return await self.async_step_init()
# Keep old step name as redirect for HA compatibility
async def async_step_global_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Redirect old step name."""
return await self.async_step_init()
async def async_step_done(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Finish and close the options flow."""
return self.async_create_entry(title="", data=self._current)
# --- Advanced Features ---
async def async_step_advanced_features(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Toggle visibility of advanced feature sections."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="advanced_features",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ADVANCED_ADAPTIVE,
default=current.get(CONF_ADVANCED_ADAPTIVE, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_PREDICTIONS,
default=current.get(CONF_ADVANCED_PREDICTIONS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SEASONAL,
default=current.get(CONF_ADVANCED_SEASONAL, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_ENVIRONMENTAL,
default=current.get(CONF_ADVANCED_ENVIRONMENTAL, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_BUDGET,
default=current.get(CONF_ADVANCED_BUDGET, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_GROUPS,
default=current.get(CONF_ADVANCED_GROUPS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_CHECKLISTS,
default=current.get(CONF_ADVANCED_CHECKLISTS, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADVANCED_SCHEDULE_TIME,
default=current.get(CONF_ADVANCED_SCHEDULE_TIME, False),
): selector.BooleanSelector(),
}
),
)
# --- Panel Access (per-user override) ---
async def async_step_panel_access(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Operator write delegation + the non-admin allowlist.
Admins always have full write access. The ``operator_write_enabled``
switch is OFF by default, so listed non-admins get only the read-only
operator view (Complete / Skip). Turn it on to grant the listed users
full create / edit / delete. Both controls are admin-only.
"""
if user_input is not None:
return self._save_and_return(user_input)
# Build the multi-select option list from the HA auth registry,
# mirroring the panel's own users/list filter (active humans only).
users = await self.hass.auth.async_get_users()
non_admin = [u for u in users if not u.is_admin and not u.system_generated and u.is_active]
options = [
selector.SelectOptionDict(
value=u.id,
label=(u.name or u.id[:8]),
)
for u in non_admin
]
current = self._current
return self.async_show_form(
step_id="panel_access",
data_schema=vol.Schema(
{
vol.Optional(
CONF_OPERATOR_WRITE_ENABLED,
default=current.get(CONF_OPERATOR_WRITE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADMIN_PANEL_USER_IDS,
default=current.get(CONF_ADMIN_PANEL_USER_IDS, []),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
),
),
}
),
description_placeholders={
"user_count": str(len(non_admin)),
},
)
# --- General Settings ---
async def async_step_general_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""General settings: warning days, notifications toggle, service, panel."""
errors: dict[str, str] = {}
if user_input is not None:
raw_service = user_input.get(CONF_NOTIFY_SERVICE, "")
# Format-only validation — NO existence check. A notify service can be
# lazily registered or momentarily absent (e.g. mobile_app_*), so we no
# longer hard-block saving it (that broke #77). A genuinely-missing
# service is surfaced afterwards by the runtime repair issue
# (notify_service_missing) instead.
normalized, error = validate_notify_service(raw_service)
if error:
errors[CONF_NOTIFY_SERVICE] = error
else:
user_input[CONF_NOTIFY_SERVICE] = normalized
# Trim + cap the optional sidebar title; blank clears the override
# (panel falls back to the default "Maintenance").
raw_title = user_input.get(CONF_PANEL_TITLE)
if isinstance(raw_title, str):
user_input[CONF_PANEL_TITLE] = raw_title.strip()[:MAX_PANEL_TITLE_LENGTH]
if not errors:
return self._save_and_return(user_input)
current = self._current
currency_code = current.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY)
currency_options = [
selector.SelectOptionDict(value=code, label=f"{code} ({symbol})") for code, symbol in BUDGET_CURRENCIES.items()
]
# Offer every notify target as a dropdown so users don't have to guess
# the slug. The merge (legacy notify services + notify entities, minus
# the generic send_message, plus the current saved value) is shared with
# the panel via build_notify_targets so the two surfaces can't drift.
# ``custom_value`` keeps free text working for not-yet-loaded targets.
notify_services = build_notify_targets(self.hass, current=current.get(CONF_NOTIFY_SERVICE, ""))
return self.async_show_form(
step_id="general_settings",
data_schema=vol.Schema(
{
vol.Optional(
CONF_DEFAULT_WARNING_DAYS,
default=current.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
): selector.NumberSelector(
selector.NumberSelectorConfig(min=_WARN_MIN, max=_WARN_MAX, step=1, mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(
CONF_BUDGET_CURRENCY,
default=currency_code,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=currency_options,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(
CONF_NOTIFICATIONS_ENABLED,
default=current.get(CONF_NOTIFICATIONS_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_SERVICE,
default=current.get(CONF_NOTIFY_SERVICE, ""),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=notify_services,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
vol.Optional(
CONF_PANEL_ENABLED,
default=current.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED),
): selector.BooleanSelector(),
# Blank clears the override → panel falls back to the default
# title ("Maintenance"). suggested_value pre-fills the current
# custom value, or empty when none is set.
vol.Optional(
CONF_PANEL_TITLE,
description={"suggested_value": current.get(CONF_PANEL_TITLE, "")},
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
}
),
errors=errors,
)
# --- Notification Settings ---
async def async_step_notification_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Per-status notification toggles, intervals, quiet hours, daily limit."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="notification_settings",
data_schema=vol.Schema(
{
# --- Due Soon ---
vol.Optional(
CONF_NOTIFY_DUE_SOON_ENABLED,
default=current.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_DUE_SOON_INTERVAL,
default=current.get(CONF_NOTIFY_DUE_SOON_INTERVAL, 24),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Overdue ---
vol.Optional(
CONF_NOTIFY_OVERDUE_ENABLED,
default=current.get(CONF_NOTIFY_OVERDUE_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_OVERDUE_INTERVAL,
default=current.get(CONF_NOTIFY_OVERDUE_INTERVAL, 12),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Triggered ---
vol.Optional(
CONF_NOTIFY_TRIGGERED_ENABLED,
default=current.get(CONF_NOTIFY_TRIGGERED_ENABLED, True),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_TRIGGERED_INTERVAL,
default=current.get(CONF_NOTIFY_TRIGGERED_INTERVAL, 0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_NOTIFY_INTERVAL_MIN, max=_NOTIFY_INTERVAL_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Quiet Hours ---
vol.Optional(
CONF_QUIET_HOURS_ENABLED,
default=current.get(CONF_QUIET_HOURS_ENABLED, True),
): selector.BooleanSelector(),
# v1.4.6 (#44 follow-up): use `or` instead of dict-default so
# empty-string / null / non-HH:MM values in storage don't
# break the whole form. HA's TimeSelector rejects an empty
# string as "Invalid time" and that error blocks the save
# button — even if the user is here to change something
# else and quiet_hours is disabled. Coerce to the sane
# fallback whenever the persisted value isn't a usable time.
vol.Optional(
CONF_QUIET_HOURS_START,
default=_safe_time(current.get(CONF_QUIET_HOURS_START), "22:00"),
): selector.TimeSelector(),
vol.Optional(
CONF_QUIET_HOURS_END,
default=_safe_time(current.get(CONF_QUIET_HOURS_END), "08:00"),
): selector.TimeSelector(),
# --- Daily Limit ---
vol.Optional(
CONF_MAX_NOTIFICATIONS_PER_DAY,
default=current.get(CONF_MAX_NOTIFICATIONS_PER_DAY, DEFAULT_MAX_NOTIFICATIONS_PER_DAY),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_MAX_PER_DAY_MIN, max=_MAX_PER_DAY_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# --- Bundling ---
vol.Optional(
CONF_NOTIFICATION_BUNDLING_ENABLED,
default=current.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
default=current.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUNDLE_MIN, max=_BUNDLE_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
# v1.4.0 (#44): notification title style
vol.Optional(
CONF_NOTIFICATION_TITLE_STYLE,
default=current.get(CONF_NOTIFICATION_TITLE_STYLE, "default"),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["default", "object_name", "task_name"],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="notification_title_style",
)
),
}
),
)
# --- Notification Actions ---
async def async_step_notification_actions(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Interactive action buttons for mobile notifications."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
return self.async_show_form(
step_id="notification_actions",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ACTION_COMPLETE_ENABLED,
default=current.get(CONF_ACTION_COMPLETE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SKIP_ENABLED,
default=current.get(CONF_ACTION_SKIP_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ACTION_SNOOZE_ENABLED,
default=current.get(CONF_ACTION_SNOOZE_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_SNOOZE_DURATION_HOURS,
default=current.get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_SNOOZE_MIN, max=_SNOOZE_MAX, step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
),
)
# --- Test Notification ---
async def async_step_test_notification(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Send a test notification and show the result."""
if user_input is not None:
# User acknowledged the result — return to menu
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
# First call: send the test notification via shared helper so the
# same actions appear here as from the panel WS call.
result_key = await send_test_notification(self.hass, self._current)
result_text = _get_test_result_text(self.hass, result_key)
return self.async_show_form(
step_id="test_notification",
data_schema=vol.Schema({}),
description_placeholders={"result": result_text},
)
# --- Budget Settings ---
async def async_step_budget_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Budget settings: monthly/yearly budget, alerts."""
if user_input is not None:
return self._save_and_return(user_input)
current = self._current
currency_code = current.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY)
currency_symbol = BUDGET_CURRENCIES.get(currency_code, "")
return self.async_show_form(
step_id="budget_settings",
data_schema=vol.Schema(
{
vol.Optional(
CONF_BUDGET_MONTHLY,
default=current.get(CONF_BUDGET_MONTHLY, 0.0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUDGET_MONTHLY_MIN,
max=_BUDGET_MONTHLY_MAX,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement=currency_symbol,
)
),
vol.Optional(
CONF_BUDGET_YEARLY,
default=current.get(CONF_BUDGET_YEARLY, 0.0),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_BUDGET_YEARLY_MIN,
max=_BUDGET_YEARLY_MAX,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement=currency_symbol,
)
),
vol.Optional(
CONF_BUDGET_ALERTS_ENABLED,
default=current.get(CONF_BUDGET_ALERTS_ENABLED, False),
): selector.BooleanSelector(),
vol.Optional(
CONF_BUDGET_ALERT_THRESHOLD,
default=current.get(CONF_BUDGET_ALERT_THRESHOLD, 80),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=_ALERT_MIN,
max=_ALERT_MAX,
step=5,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="%",
)
),
}
),
)
# --- Manage Groups ---
async def async_step_manage_groups(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""List and manage maintenance groups."""
from .const import CONF_GROUPS
current = self._current
groups = current.get(CONF_GROUPS, {})
if user_input is not None:
selected = user_input.get("selected_group")
if selected == "_add_new":
return await self.async_step_add_group()
if selected and selected in groups:
return await self._delete_group(selected)
if not groups:
# No groups yet — go directly to add
return await self.async_step_add_group()
options = [
selector.SelectOptionDict(
value=gid,
label=f"{gdata.get('name', gid)} ({len(gdata.get('task_refs', []))} tasks)",
)
for gid, gdata in groups.items()
]
options.append(selector.SelectOptionDict(value="_add_new", label="+ Add New Group"))
return self.async_show_form(
step_id="manage_groups",
data_schema=vol.Schema(
{
vol.Required("selected_group"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
mode=selector.SelectSelectorMode.LIST,
)
),
}
),
)
async def async_step_add_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Add a new maintenance group."""
from .const import CONF_GROUPS
from .helpers.sanitize import cap_group_fields
if user_input is not None:
group_name = user_input.get("group_name", "").strip()
if group_name:
group_id = uuid4().hex
merged = self._current
groups = dict(merged.get(CONF_GROUPS, {}))
new_group = {
"name": group_name,
"description": user_input.get("group_description", ""),
"task_refs": [],
}
cap_group_fields(new_group)
groups[group_id] = new_group
merged[CONF_GROUPS] = groups
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
return self.async_show_form(
step_id="add_group",
data_schema=vol.Schema(
{
vol.Required("group_name"): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("group_description", default=""): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
multiline=True,
)
),
}
),
)
async def _delete_group(self, group_id: str) -> ConfigFlowResult:
"""Delete a group and return to menu."""
from .const import CONF_GROUPS
merged = self._current
groups = dict(merged.get(CONF_GROUPS, {}))
groups.pop(group_id, None)
merged[CONF_GROUPS] = groups
self.hass.config_entries.async_update_entry(self.config_entry, options=merged)
return self.async_show_menu(
step_id="global_init",
menu_options=self._menu_options(),
)
@@ -0,0 +1,30 @@
"""Per-object task options flow — assembled from focused mixins.
MaintenanceOptionsFlow was split (module modularization refactor) into
config_flow_options_task_base + _crud / _add / _trigger / _adaptive / _object
mixins. This module assembles them into the final class and keeps the
`MaintenanceOptionsFlow` name importable from the original path (config_flow.py
and config_flow_options.py import it unchanged).
"""
from __future__ import annotations
from .config_flow_options_task_adaptive import AdaptiveMixin
from .config_flow_options_task_add import AddTaskMixin
from .config_flow_options_task_base import _OptionsFlowBase
from .config_flow_options_task_crud import TaskCrudMixin
from .config_flow_options_task_object import ObjectSettingsMixin
from .config_flow_options_task_trigger import TriggerStepsMixin
__all__ = ["MaintenanceOptionsFlow"]
class MaintenanceOptionsFlow(
TaskCrudMixin,
AddTaskMixin,
TriggerStepsMixin,
AdaptiveMixin,
ObjectSettingsMixin,
_OptionsFlowBase,
):
"""Handle maintenance object options (per-object task CRUD + triggers)."""
@@ -0,0 +1,182 @@
"""Adaptive-scheduling step + schema (mixin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .const import (
CONF_ADAPTIVE_CONFIG,
CONF_ADAPTIVE_ENABLED,
CONF_ADAPTIVE_EWA_ALPHA,
CONF_ADAPTIVE_MAX_INTERVAL,
CONF_ADAPTIVE_MIN_INTERVAL,
CONF_ENVIRONMENTAL_ENTITY,
CONF_SENSOR_PREDICTION_ENABLED,
CONF_TASKS,
DEFAULT_ADAPTIVE_EWA_ALPHA,
DEFAULT_ADAPTIVE_MAX_INTERVAL,
DEFAULT_ADAPTIVE_MIN_INTERVAL,
)
from .helpers.schedule import (
read_legacy_fields,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
class AdaptiveMixin:
"""Adaptive scheduling configuration."""
# -- provided by the assembled MaintenanceOptionsFlow --
if TYPE_CHECKING:
config_entry: ConfigEntry
_selected_task_id: str | None
def _show_task_action_menu(self) -> ConfigFlowResult: ...
def _update_config_entry(self, new_data: dict[str, Any]) -> None: ...
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_adaptive_scheduling(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure adaptive scheduling for a task."""
# Read adaptive_config from Store (merged) data
rd = getattr(self.config_entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
if store is not None:
current_adaptive = store.get_adaptive_config(self._selected_task_id or "") or {}
else:
current_adaptive = task.get(CONF_ADAPTIVE_CONFIG, {})
if user_input is not None:
if user_input.get("go_back"):
return self._show_task_action_menu()
enabled = user_input.get(CONF_ADAPTIVE_ENABLED, False)
adaptive_config: dict[str, Any] = dict(current_adaptive)
adaptive_config["enabled"] = enabled
adaptive_config[CONF_ADAPTIVE_EWA_ALPHA] = user_input.get(CONF_ADAPTIVE_EWA_ALPHA, DEFAULT_ADAPTIVE_EWA_ALPHA)
min_iv = int(user_input.get(CONF_ADAPTIVE_MIN_INTERVAL, DEFAULT_ADAPTIVE_MIN_INTERVAL))
max_iv = int(user_input.get(CONF_ADAPTIVE_MAX_INTERVAL, DEFAULT_ADAPTIVE_MAX_INTERVAL))
if min_iv > max_iv:
return self.async_show_form(
step_id="adaptive_scheduling",
data_schema=self._adaptive_schema(current_adaptive, task),
errors={CONF_ADAPTIVE_MIN_INTERVAL: "min_exceeds_max"},
)
adaptive_config[CONF_ADAPTIVE_MIN_INTERVAL] = min_iv
adaptive_config[CONF_ADAPTIVE_MAX_INTERVAL] = max_iv
# Seasonal awareness toggle
adaptive_config["seasonal_enabled"] = user_input.get("seasonal_enabled", True)
# Sensor prediction toggle (Phase 3)
adaptive_config[CONF_SENSOR_PREDICTION_ENABLED] = user_input.get(CONF_SENSOR_PREDICTION_ENABLED, True)
env_entity = user_input.get(CONF_ENVIRONMENTAL_ENTITY)
if env_entity:
adaptive_config["environmental_entity"] = env_entity
else:
adaptive_config.pop("environmental_entity", None)
adaptive_config.pop("environmental_attribute", None)
# Store base_interval for blending if not yet set
if "base_interval" not in adaptive_config:
base = read_legacy_fields(task)["interval_days"]
adaptive_config["base_interval"] = base if base is not None else 30
if store is not None:
store.set_adaptive_config(self._selected_task_id or "", adaptive_config)
store.async_delay_save()
else:
# Legacy: write to ConfigEntry.data
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
updated_task[CONF_ADAPTIVE_CONFIG] = adaptive_config
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
return self._show_task_action_menu()
return self.async_show_form(
step_id="adaptive_scheduling",
data_schema=self._adaptive_schema(current_adaptive, task),
description_placeholders={
"task_name": task.get("name", ""),
},
)
def _adaptive_schema(
self,
current_adaptive: dict[str, Any],
task: dict[str, Any],
) -> vol.Schema:
"""Build the adaptive scheduling form schema."""
env_entity = current_adaptive.get("environmental_entity")
env_key = (
vol.Optional(CONF_ENVIRONMENTAL_ENTITY, default=env_entity) if env_entity else vol.Optional(CONF_ENVIRONMENTAL_ENTITY)
)
return vol.Schema(
{
vol.Optional(
CONF_ADAPTIVE_ENABLED,
default=current_adaptive.get("enabled", False),
): selector.BooleanSelector(),
vol.Optional(
CONF_ADAPTIVE_EWA_ALPHA,
default=current_adaptive.get(CONF_ADAPTIVE_EWA_ALPHA, DEFAULT_ADAPTIVE_EWA_ALPHA),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1,
max=0.9,
step=0.1,
mode=selector.NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_ADAPTIVE_MIN_INTERVAL,
default=current_adaptive.get(CONF_ADAPTIVE_MIN_INTERVAL, DEFAULT_ADAPTIVE_MIN_INTERVAL),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=365,
step=1,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
)
),
vol.Optional(
CONF_ADAPTIVE_MAX_INTERVAL,
default=current_adaptive.get(CONF_ADAPTIVE_MAX_INTERVAL, DEFAULT_ADAPTIVE_MAX_INTERVAL),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=3650,
step=1,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
)
),
vol.Optional(
"seasonal_enabled",
default=current_adaptive.get("seasonal_enabled", True),
): selector.BooleanSelector(),
vol.Optional(
CONF_SENSOR_PREDICTION_ENABLED,
default=current_adaptive.get(CONF_SENSOR_PREDICTION_ENABLED, True),
): selector.BooleanSelector(),
env_key: selector.EntitySelector(
selector.EntitySelectorConfig(
domain=["sensor"],
device_class=["temperature", "humidity", "pressure"],
multiple=False,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
)
@@ -0,0 +1,312 @@
"""Add-task + schedule-kind steps (mixin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .config_flow_helpers import (
CALENDAR_KIND_VALUES,
apply_interval_unit,
calendar_schema,
interval_unit_selector,
schedule_from_calendar_input,
)
from .const import (
CONF_TASK_DUE_DATE,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_ANCHOR,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_NAME,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_READING_UNIT,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
DEFAULT_INTERVAL_DAYS,
MaintenanceTypeEnum,
ScheduleType,
)
from .helpers.global_options import get_default_warning_days
from .helpers.schedule import (
KIND_WEEKDAYS,
)
from .helpers.task_fields import TASK_PRIORITIES, WARNING_DAYS_RANGE
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from homeassistant.core import HomeAssistant
class AddTaskMixin:
"""Add a new task and pick its schedule kind."""
# -- provided by the assembled MaintenanceOptionsFlow --
if TYPE_CHECKING:
hass: HomeAssistant
_on_cancel: Callable[[], ConfigFlowResult | Awaitable[ConfigFlowResult]] | None
def _save_new_task(self) -> ConfigFlowResult: ...
def _show_init_menu(self) -> ConfigFlowResult: ...
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ...
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Add a new task — step 1: name, type, schedule."""
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
self._current_task = {
CONF_TASK_NAME: user_input[CONF_TASK_NAME],
CONF_TASK_TYPE: user_input.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CLEANING),
CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE],
}
if user_input.get(CONF_TASK_ICON):
self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON]
if user_input.get(CONF_TASK_PRIORITY):
self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY]
if user_input.get(CONF_TASK_LABELS_TEXT):
self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT]
if user_input.get(CONF_TASK_READING_UNIT):
self._current_task[CONF_TASK_READING_UNIT] = user_input[CONF_TASK_READING_UNIT].strip()
self._trigger_on_complete = self._save_new_task
self._on_cancel = self._show_init_menu
schedule = user_input[CONF_TASK_SCHEDULE_TYPE]
if schedule == ScheduleType.TIME_BASED:
return await self.async_step_opt_time_based()
if schedule in CALENDAR_KIND_VALUES:
return await self.async_step_opt_calendar()
if schedule == ScheduleType.SENSOR_BASED:
return await self.async_step_opt_sensor_select()
if schedule == ScheduleType.ONE_TIME:
return await self.async_step_opt_one_time()
# Manual
return await self.async_step_opt_manual()
type_options = [t.value for t in MaintenanceTypeEnum]
# Recurrence kinds: time-based, the calendar kinds (Phase 4), then the
# trigger/one-time/manual kinds.
schedule_options = [
ScheduleType.TIME_BASED,
*CALENDAR_KIND_VALUES,
ScheduleType.SENSOR_BASED,
ScheduleType.ONE_TIME,
ScheduleType.MANUAL,
]
return self.async_show_form(
step_id="add_task",
data_schema=vol.Schema(
{
vol.Required(CONF_TASK_NAME): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector(
selector.SelectSelectorConfig(
options=type_options,
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="maintenance_type",
)
),
vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector(
selector.SelectSelectorConfig(
options=schedule_options,
mode=selector.SelectSelectorMode.LIST,
translation_key="schedule_type",
)
),
vol.Optional(CONF_TASK_ICON): selector.IconSelector(),
vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(TASK_PRIORITIES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="task_priority",
)
),
vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³").
vol.Optional(CONF_TASK_READING_UNIT): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
)
async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure time-based schedule for new task."""
errors: dict[str, str] = {}
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
if not interval or interval <= 0:
errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval"
else:
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
apply_interval_unit(self._current_task, user_input)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
self._current_task[CONF_TASK_INTERVAL_ANCHOR] = user_input.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
last_performed = user_input.get("last_performed")
if last_performed:
self._current_task["last_performed"] = str(last_performed)
return self._save_new_task()
return self.async_show_form(
step_id="opt_time_based",
data_schema=vol.Schema(
{
vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=3650, step=1, mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(CONF_TASK_INTERVAL_ANCHOR, default="completion"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="completion", label="From completion date"),
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional("last_performed"): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
)
async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a calendar recurrence kind for a new task."""
errors: dict[str, str] = {}
kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS)
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
schedule = schedule_from_calendar_input(kind, user_input)
if schedule is None:
errors["base"] = "invalid_schedule"
else:
self._current_task["schedule"] = schedule
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
if user_input.get("last_performed"):
self._current_task["last_performed"] = str(user_input["last_performed"])
return self._save_new_task()
schema = calendar_schema(kind).extend(
{
vol.Optional("last_performed"): selector.DateSelector(),
vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
)
return self.async_show_form(
step_id="opt_calendar",
data_schema=schema,
errors=errors,
description_placeholders={"kind": kind},
)
async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a one-time (non-recurring) task for new task."""
errors: dict[str, str] = {}
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
due_date = user_input.get(CONF_TASK_DUE_DATE)
if not due_date:
errors[CONF_TASK_DUE_DATE] = "invalid_due_date"
else:
self._current_task[CONF_TASK_DUE_DATE] = str(due_date)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
return self._save_new_task()
return self.async_show_form(
step_id="opt_one_time",
data_schema=vol.Schema(
{
vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
)
async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure manual schedule for new task."""
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
if user_input.get(CONF_TASK_NOTES):
self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
return self._save_new_task()
return self.async_show_form(
step_id="opt_manual",
data_schema=vol.Schema(
{
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TASK_NOTES): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
)
@@ -0,0 +1,203 @@
"""Base for the per-object task options flow: shared state + B1 helpers.
The MaintenanceOptionsFlow steps are split across sibling mixins; this base
holds the __init__ state, the single persist path, and the init/menu steps
they all rely on. Assembled in config_flow_options_task.py."""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.core import State
from .config_flow_trigger import TriggerConfigMixin
from .const import (
CONF_ADVANCED_ADAPTIVE,
CONF_ADVANCED_CHECKLISTS,
CONF_OBJECT,
CONF_TASK_DUE_DATE,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_ANCHOR,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_NAME,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceTypeEnum,
ScheduleType,
)
from .helpers.global_options import get_default_warning_days
from .helpers.schedule import (
normalize_task_storage,
)
class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow):
"""Shared state + core steps for the task options flow."""
def __init__(self) -> None:
"""Initialize maintenance options flow."""
self._current_task: dict[str, Any] = {}
self._selected_task_id: str | None = None
self._trigger_entity_id: str | None = None
self._trigger_entity_state: State | None = None
self._trigger_on_complete = self._save_new_task
def _update_config_entry(self, new_data: dict[str, Any]) -> None:
"""Update the config entry with new data.
Recurrence is normalized to the nested ``schedule`` storage here — the
single persist path for add/edit task — so every saved task converges
on one storage shape (idempotent for already-nested tasks).
"""
tasks = new_data.get(CONF_TASKS)
if tasks:
new_data = {
**new_data,
CONF_TASKS: {tid: normalize_task_storage(td) for tid, td in tasks.items()},
}
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
def _save_new_task(self) -> ConfigFlowResult:
"""Save the current task and return to init."""
from homeassistant.util import dt as dt_util
from .helpers.sanitize import cap_task_fields, parse_labels_text
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
"object_id": self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""),
"name": self._current_task.get(CONF_TASK_NAME, ""),
"type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM),
"enabled": True,
"schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED),
"warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)),
# Anchor for next_due fallback when last_performed is None (issue #30).
"created_at": dt_util.now().date().isoformat(),
}
# Calendar kinds carry a pre-built nested schedule; normalize (in
# _update_config_entry) treats it as authoritative over the flat fields.
if "schedule" in self._current_task:
task_data["schedule"] = self._current_task["schedule"]
if CONF_TASK_INTERVAL_DAYS in self._current_task:
task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
if CONF_TASK_INTERVAL_UNIT in self._current_task:
task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT]
if CONF_TASK_DUE_DATE in self._current_task:
task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE]
anchor = self._current_task.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
if anchor != "completion":
task_data["interval_anchor"] = anchor
if "trigger_config" in self._current_task:
task_data["trigger_config"] = self._current_task["trigger_config"]
if CONF_TASK_NOTES in self._current_task:
task_data["notes"] = self._current_task[CONF_TASK_NOTES]
if CONF_TASK_ICON in self._current_task:
task_data["custom_icon"] = self._current_task[CONF_TASK_ICON]
if CONF_TASK_PRIORITY in self._current_task:
task_data["priority"] = self._current_task[CONF_TASK_PRIORITY]
if self._current_task.get(CONF_TASK_LABELS_TEXT):
task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT])
cap_task_fields(task_data)
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
obj = dict(new_data.get(CONF_OBJECT, {}))
task_ids = list(obj.get("task_ids", []))
task_ids.append(task_id)
obj["task_ids"] = task_ids
new_data[CONF_OBJECT] = obj
self._update_config_entry(new_data)
# Initialize dynamic state in Store
rd = getattr(self.config_entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
last_performed = self._current_task.get("last_performed")
if store is not None:
store.init_task(task_id, last_performed=last_performed)
store.async_delay_save()
elif last_performed:
# Legacy: put last_performed in ConfigEntry.data
task_data["last_performed"] = last_performed
task_data["history"] = []
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
self._current_task = {}
return self._show_init_menu()
def _show_init_menu(self) -> ConfigFlowResult:
"""Show the init menu (sync helper for callbacks)."""
obj_data = self.config_entry.data.get(CONF_OBJECT, {})
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
object_info = f"{obj_data.get('name', 'Unknown')}{len(tasks_data)} task(s)"
return self.async_show_menu(
step_id="init",
menu_options=["manage_tasks", "add_task", "object_settings", "done"],
description_placeholders={"object_info": object_info},
)
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Show main options menu."""
return self._show_init_menu()
async def async_step_done(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Close the options flow."""
# Flush store and reload to pick up config changes from this flow
rd = getattr(self.config_entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
await store.async_save()
self.hass.async_create_task(self.hass.config_entries.async_reload(self.config_entry.entry_id))
return self.async_create_entry(title="", data=self.config_entry.options)
def _get_global_options(self) -> dict[str, Any]:
"""Get global options from the global config entry."""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return dict(entry.options or entry.data)
return {}
def _build_task_action_menu(self) -> list[str]:
"""Build the task_action menu options list."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
global_opts = self._get_global_options()
menu = ["edit_task", "edit_trigger"]
if task.get("trigger_config"):
menu.append("remove_trigger")
if global_opts.get(CONF_ADVANCED_CHECKLISTS, False):
menu.append("edit_checklist")
if global_opts.get(CONF_ADVANCED_ADAPTIVE, False):
menu.append("adaptive_scheduling")
menu.extend(["delete_task", "manage_tasks"])
return menu
def _show_task_action_menu(self) -> ConfigFlowResult:
"""Show the task_action menu (sync helper for callbacks)."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
return self.async_show_menu(
step_id="task_action",
menu_options=self._build_task_action_menu(),
description_placeholders={"task_name": task.get("name", "Unknown")},
)
@@ -0,0 +1,531 @@
"""Task manage / edit / delete / checklist steps (mixin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .config_flow_helpers import (
CALENDAR_KIND_VALUES,
calendar_current,
calendar_schema,
interval_unit_selector,
schedule_from_calendar_input,
)
from .const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_RESPONSIBLE_USER_ID,
CONF_TASK_ASSIGNEE_POOL,
CONF_TASK_DOCUMENTATION_URL,
CONF_TASK_DUE_DATE,
CONF_TASK_ENABLED,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_ANCHOR,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_LAST_PERFORMED,
CONF_TASK_NAME,
CONF_TASK_NFC_TAG,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_READING_UNIT,
CONF_TASK_ROTATION_STRATEGY,
CONF_TASK_SCHEDULE_TIME,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
CONF_TASKS,
DEFAULT_INTERVAL_DAYS,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
ROTATION_STRATEGIES,
MaintenanceTypeEnum,
ScheduleType,
)
from .helpers.global_options import get_default_warning_days
from .helpers.schedule import (
read_legacy_fields,
)
from .helpers.task_fields import (
EARLIEST_COMPLETION_RANGE,
TASK_PRIORITIES,
WARNING_DAYS_RANGE,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
class TaskCrudMixin:
"""Manage, edit, delete tasks + checklist editing."""
# -- provided by the assembled MaintenanceOptionsFlow --
if TYPE_CHECKING:
hass: HomeAssistant
config_entry: ConfigEntry
def _get_global_options(self) -> dict[str, Any]: ...
def _show_init_menu(self) -> ConfigFlowResult: ...
def _show_task_action_menu(self) -> ConfigFlowResult: ...
def _update_config_entry(self, new_data: dict[str, Any]) -> None: ...
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_manage_tasks(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""List and manage existing tasks."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
selected = user_input.get("selected_task")
if selected and selected in tasks_data:
self._selected_task_id = selected
return await self.async_step_task_action()
return self._show_init_menu()
task_options = [
selector.SelectOptionDict(
value=task_id,
label=f"{task.get('name', 'Unknown')} ({task.get('type', 'custom')})",
)
for task_id, task in tasks_data.items()
]
if not task_options:
return self._show_init_menu()
return self.async_show_form(
step_id="manage_tasks",
data_schema=vol.Schema(
{
vol.Required("selected_task"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=task_options,
mode=selector.SelectSelectorMode.LIST,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
)
async def async_step_task_action(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Show actions for selected task."""
return self._show_task_action_menu()
async def async_step_edit_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Edit an existing task."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
errors: dict[str, str] = {}
if user_input is not None:
if user_input.get("go_back"):
return self._show_task_action_menu()
# Validate NFC tag uniqueness before saving
nfc_val = (user_input.get(CONF_TASK_NFC_TAG) or "").strip()
if nfc_val:
from .websocket.tasks import _check_nfc_tag_duplicate
dup_warn = _check_nfc_tag_duplicate(
self.hass,
nfc_val,
exclude_task_id=self._selected_task_id,
)
if dup_warn:
errors[CONF_TASK_NFC_TAG] = "nfc_tag_duplicate"
if not errors:
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
updated_task["name"] = user_input.get(CONF_TASK_NAME, updated_task.get("name"))
updated_task["type"] = user_input.get(CONF_TASK_TYPE, updated_task.get("type"))
if user_input.get(CONF_TASK_INTERVAL_DAYS):
updated_task["interval_days"] = int(user_input[CONF_TASK_INTERVAL_DAYS])
if CONF_TASK_INTERVAL_UNIT in user_input:
unit = user_input[CONF_TASK_INTERVAL_UNIT]
if unit and unit != "days":
updated_task["interval_unit"] = unit
else:
updated_task.pop("interval_unit", None)
if user_input.get(CONF_TASK_DUE_DATE):
updated_task["due_date"] = str(user_input[CONF_TASK_DUE_DATE])
if CONF_TASK_INTERVAL_ANCHOR in user_input:
updated_task["interval_anchor"] = user_input[CONF_TASK_INTERVAL_ANCHOR]
# Calendar kinds: rebuild the nested schedule from the form fields
# (normalize, in _update_config_entry, treats it as authoritative).
edit_kind = read_legacy_fields(task)["schedule_type"]
if edit_kind in CALENDAR_KIND_VALUES:
schedule = schedule_from_calendar_input(edit_kind, user_input)
if schedule is not None:
for key in ("interval_days", "interval_unit", "interval_anchor", "due_date"):
updated_task.pop(key, None)
updated_task["schedule"] = schedule
# schedule_time only present when global advanced flag is on; clear by submitting "".
if CONF_TASK_SCHEDULE_TIME in user_input:
sched = (user_input.get(CONF_TASK_SCHEDULE_TIME) or "").strip()
if sched:
updated_task["schedule_time"] = sched
else:
updated_task.pop("schedule_time", None)
updated_task["warning_days"] = int(
user_input.get(
CONF_TASK_WARNING_DAYS,
updated_task.get("warning_days", get_default_warning_days(self.hass)),
)
)
updated_task[CONF_TASK_ENABLED] = user_input.get(CONF_TASK_ENABLED, updated_task.get(CONF_TASK_ENABLED, True))
if user_input.get(CONF_TASK_NOTES):
updated_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
if user_input.get(CONF_TASK_DOCUMENTATION_URL):
updated_task[CONF_TASK_DOCUMENTATION_URL] = user_input[CONF_TASK_DOCUMENTATION_URL]
if user_input.get(CONF_TASK_LAST_PERFORMED):
updated_task[CONF_TASK_LAST_PERFORMED] = str(user_input[CONF_TASK_LAST_PERFORMED])
pool = user_input.get(CONF_TASK_ASSIGNEE_POOL, [])
if pool:
updated_task["assignee_pool"] = pool
else:
updated_task.pop("assignee_pool", None)
rot = user_input.get(CONF_TASK_ROTATION_STRATEGY, "")
if rot:
updated_task["rotation_strategy"] = rot
else:
updated_task.pop("rotation_strategy", None)
ecd = user_input.get("earliest_completion_days")
if ecd is not None and ecd != "":
updated_task["earliest_completion_days"] = int(ecd)
else:
updated_task.pop("earliest_completion_days", None)
resp_user = user_input.get(CONF_RESPONSIBLE_USER_ID, "")
if resp_user:
updated_task[CONF_RESPONSIBLE_USER_ID] = resp_user
else:
updated_task.pop(CONF_RESPONSIBLE_USER_ID, None)
icon_val = user_input.get(CONF_TASK_ICON, "")
updated_task[CONF_TASK_PRIORITY] = user_input.get(CONF_TASK_PRIORITY, "normal")
labels_text = user_input.get(CONF_TASK_LABELS_TEXT, "")
if labels_text.strip():
from .helpers.sanitize import parse_labels_text
updated_task["labels"] = parse_labels_text(labels_text)
else:
updated_task.pop("labels", None)
if icon_val:
updated_task[CONF_TASK_ICON] = icon_val
else:
updated_task.pop(CONF_TASK_ICON, None)
if nfc_val:
updated_task[CONF_TASK_NFC_TAG] = nfc_val
else:
updated_task.pop(CONF_TASK_NFC_TAG, None)
# v2.20 (#83): reading unit — clear by submitting "".
if CONF_TASK_READING_UNIT in user_input:
ru = (user_input.get(CONF_TASK_READING_UNIT) or "").strip()
if ru:
updated_task[CONF_TASK_READING_UNIT] = ru
else:
updated_task.pop(CONF_TASK_READING_UNIT, None)
from .helpers.sanitize import cap_task_fields
cap_task_fields(updated_task)
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
return self._show_task_action_menu()
type_options = [t.value for t in MaintenanceTypeEnum]
# Build optional keys with defaults only when the task has a value
last_performed_key = (
vol.Optional(CONF_TASK_LAST_PERFORMED, default=task.get(CONF_TASK_LAST_PERFORMED))
if task.get(CONF_TASK_LAST_PERFORMED)
else vol.Optional(CONF_TASK_LAST_PERFORMED)
)
notes_key = (
vol.Optional(CONF_TASK_NOTES, default=task.get(CONF_TASK_NOTES))
if task.get(CONF_TASK_NOTES)
else vol.Optional(CONF_TASK_NOTES)
)
doc_url_key = (
vol.Optional(CONF_TASK_DOCUMENTATION_URL, default=task.get(CONF_TASK_DOCUMENTATION_URL))
if task.get(CONF_TASK_DOCUMENTATION_URL)
else vol.Optional(CONF_TASK_DOCUMENTATION_URL)
)
icon_key = (
vol.Optional(CONF_TASK_ICON, default=task.get(CONF_TASK_ICON))
if task.get(CONF_TASK_ICON)
else vol.Optional(CONF_TASK_ICON)
)
nfc_tag_key = (
vol.Optional(CONF_TASK_NFC_TAG, default=task.get(CONF_TASK_NFC_TAG))
if task.get(CONF_TASK_NFC_TAG)
else vol.Optional(CONF_TASK_NFC_TAG)
)
reading_unit_key = (
vol.Optional(CONF_TASK_READING_UNIT, default=task.get(CONF_TASK_READING_UNIT))
if task.get(CONF_TASK_READING_UNIT)
else vol.Optional(CONF_TASK_READING_UNIT)
)
due_date_key = (
vol.Required(CONF_TASK_DUE_DATE, default=task.get(CONF_TASK_DUE_DATE))
if task.get(CONF_TASK_DUE_DATE)
else vol.Required(CONF_TASK_DUE_DATE)
)
# Build user dropdown options
users = await self.hass.auth.async_get_users()
user_options = [selector.SelectOptionDict(value="", label="\u2014")]
for user in users:
if not user.is_active or user.system_generated:
continue
user_options.append(selector.SelectOptionDict(value=user.id, label=user.name or user.id))
user_id_default = task.get(CONF_RESPONSIBLE_USER_ID, "")
user_id_key = vol.Optional(CONF_RESPONSIBLE_USER_ID, default=user_id_default)
# Rotation pool options = the real users (no empty sentinel).
pool_options = [o for o in user_options if o["value"]]
pool_default = task.get("assignee_pool", [])
rotation_default = task.get("rotation_strategy", "")
# Completion window (optional): only carry a default when one is stored,
# so the NumberSelector renders empty for the "no restriction" case.
ecd_stored = task.get("earliest_completion_days")
ecd_key = (
vol.Optional("earliest_completion_days")
if ecd_stored is None
else vol.Optional("earliest_completion_days", default=ecd_stored)
)
# Prefill the recurrence from whichever storage shape this task uses
# (flat v2.6.x or nested `schedule`). Reading raw flat keys here would
# silently reset a migrated task's interval on the next save (issue #58).
sched = read_legacy_fields(task)
return self.async_show_form(
step_id="edit_task",
data_schema=vol.Schema(
{
vol.Required(CONF_TASK_NAME, default=task.get("name", "")): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Required(CONF_TASK_TYPE, default=task.get("type", MaintenanceTypeEnum.CLEANING)): selector.SelectSelector(
selector.SelectSelectorConfig(
options=type_options,
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="maintenance_type",
)
),
**(
{
vol.Optional(
CONF_TASK_INTERVAL_DAYS,
default=sched["interval_days"] or DEFAULT_INTERVAL_DAYS,
): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=3650, step=1, mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(
CONF_TASK_INTERVAL_UNIT,
default=sched["interval_unit"],
): interval_unit_selector(),
vol.Optional(
CONF_TASK_INTERVAL_ANCHOR,
default=sched["interval_anchor"],
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="completion", label="From completion date"),
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
**(
{
vol.Optional(
CONF_TASK_SCHEDULE_TIME,
default=task.get("schedule_time", ""),
): selector.TimeSelector(),
}
if self._get_global_options().get(CONF_ADVANCED_SCHEDULE_TIME, False)
else dict[Any, Any]()
),
}
if sched["schedule_type"] == ScheduleType.TIME_BASED
else dict[Any, Any]()
),
**(
{due_date_key: selector.DateSelector()}
if sched["schedule_type"] == ScheduleType.ONE_TIME
else dict[Any, Any]()
),
# Calendar kinds (Phase 4): per-kind fields, prefilled from
# the task's nested schedule.
**(
calendar_schema(sched["schedule_type"], calendar_current(task)).schema
if sched["schedule_type"] in CALENDAR_KIND_VALUES
else dict[Any, Any]()
),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=task.get("warning_days", get_default_warning_days(self.hass)),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
ecd_key: selector.NumberSelector(
selector.NumberSelectorConfig(
min=EARLIEST_COMPLETION_RANGE[0],
max=EARLIEST_COMPLETION_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_TASK_ENABLED,
default=task.get(CONF_TASK_ENABLED, True),
): selector.BooleanSelector(),
notes_key: selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True)
),
doc_url_key: selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.URL)),
last_performed_key: selector.DateSelector(),
user_id_key: selector.SelectSelector(
selector.SelectSelectorConfig(
options=user_options,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
**(
{
vol.Optional(CONF_TASK_ASSIGNEE_POOL, default=pool_default): selector.SelectSelector(
selector.SelectSelectorConfig(
options=pool_options,
mode=selector.SelectSelectorMode.LIST,
multiple=True,
)
),
vol.Optional(CONF_TASK_ROTATION_STRATEGY, default=rotation_default): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["", *ROTATION_STRATEGIES],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="rotation_strategy",
)
),
}
if len(pool_options) >= 2
else dict[Any, Any]()
),
icon_key: selector.IconSelector(),
vol.Optional(CONF_TASK_PRIORITY, default=task.get(CONF_TASK_PRIORITY, "normal")): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(TASK_PRIORITIES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="task_priority",
)
),
vol.Optional(
CONF_TASK_LABELS_TEXT,
default=", ".join(task.get("labels", [])),
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
nfc_tag_key: selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
reading_unit_key: selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
description_placeholders={
"task_name": task.get("name", ""),
},
)
async def async_step_edit_checklist(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Edit the checklist for a task."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
if user_input is not None:
if user_input.get("go_back"):
return self._show_task_action_menu()
# Parse textarea: one step per line, strip empty lines.
# Per-item length and total-count caps mirror the WS schema so
# neither path can grow ConfigEntry.data without bound.
raw = user_input.get("checklist_text", "")
items = [line.strip()[:MAX_CHECKLIST_ITEM_LENGTH] for line in raw.splitlines() if line.strip()][:MAX_CHECKLIST_ITEMS]
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
updated_task["checklist"] = items
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
return self._show_task_action_menu()
current_checklist = task.get("checklist", [])
default_text = "\n".join(current_checklist)
return self.async_show_form(
step_id="edit_checklist",
data_schema=vol.Schema(
{
vol.Optional("checklist_text", default=default_text): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
multiline=True,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
description_placeholders={
"task_name": task.get("name", ""),
},
)
async def async_step_delete_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Confirm and delete a task."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
if user_input is not None:
if user_input.get("go_back"):
return self._show_task_action_menu()
if user_input.get("confirm"):
# Delegate to the shared delete helper so this surface gets
# the SAME side-state cleanup as the WS command and the
# delete_task service (Store, notification state, registry
# entries, group refs, vacation exempt list, repair issues).
# The inline copy this replaced missed most of those.
from .websocket.tasks_crud import async_delete_task
if self._selected_task_id:
await async_delete_task(self.hass, self.config_entry, self._selected_task_id)
return self._show_init_menu()
return self._show_task_action_menu()
return self.async_show_form(
step_id="delete_task",
data_schema=vol.Schema(
{
vol.Required("confirm", default=False): selector.BooleanSelector(),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
description_placeholders={
"task_name": task.get("name", ""),
},
)
@@ -0,0 +1,141 @@
"""Object-settings (metadata) step (mixin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .const import (
CONF_OBJECT,
CONF_OBJECT_AREA,
CONF_OBJECT_DOCUMENTATION_URL,
CONF_OBJECT_INSTALLATION_DATE,
CONF_OBJECT_MANUFACTURER,
CONF_OBJECT_MODEL,
CONF_OBJECT_NAME,
CONF_OBJECT_NOTES,
CONF_OBJECT_SERIAL_NUMBER,
CONF_OBJECT_WARRANTY_EXPIRY,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
class ObjectSettingsMixin:
"""Edit the maintenance object's metadata."""
# -- provided by the assembled MaintenanceOptionsFlow --
if TYPE_CHECKING:
hass: HomeAssistant
config_entry: ConfigEntry
def _show_init_menu(self) -> ConfigFlowResult: ...
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_object_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Edit object settings."""
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
from .helpers.sanitize import cap_object_fields
new_data = dict(self.config_entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
# Migrate name-slug-based unique_ids BEFORE overwriting the name
# (see helpers.entity_rename.migrate_object_unique_ids).
from .helpers.entity_rename import migrate_object_unique_ids
migrate_object_unique_ids(
self.hass,
self.config_entry,
obj.get("name"),
user_input.get(CONF_OBJECT_NAME, obj.get("name")),
)
obj[CONF_OBJECT_NAME] = user_input.get(CONF_OBJECT_NAME, obj.get("name"))
obj[CONF_OBJECT_MANUFACTURER] = user_input.get(CONF_OBJECT_MANUFACTURER)
obj[CONF_OBJECT_MODEL] = user_input.get(CONF_OBJECT_MODEL)
obj[CONF_OBJECT_SERIAL_NUMBER] = user_input.get(CONF_OBJECT_SERIAL_NUMBER)
obj[CONF_OBJECT_AREA] = user_input.get(CONF_OBJECT_AREA)
if user_input.get(CONF_OBJECT_INSTALLATION_DATE):
obj[CONF_OBJECT_INSTALLATION_DATE] = str(user_input[CONF_OBJECT_INSTALLATION_DATE])
if user_input.get(CONF_OBJECT_WARRANTY_EXPIRY):
obj[CONF_OBJECT_WARRANTY_EXPIRY] = str(user_input[CONF_OBJECT_WARRANTY_EXPIRY])
# v1.4.0 (#43)
obj[CONF_OBJECT_DOCUMENTATION_URL] = user_input.get(CONF_OBJECT_DOCUMENTATION_URL) or None
# v1.4.10 (#46)
obj[CONF_OBJECT_NOTES] = (user_input.get(CONF_OBJECT_NOTES) or "").strip() or None
cap_object_fields(obj)
new_data[CONF_OBJECT] = obj
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
title=obj[CONF_OBJECT_NAME],
)
return self._show_init_menu()
obj = self.config_entry.data.get(CONF_OBJECT, {})
# Build optional keys with defaults only when the object has a value
area_key = (
vol.Optional(CONF_OBJECT_AREA, default=obj.get(CONF_OBJECT_AREA))
if obj.get(CONF_OBJECT_AREA)
else vol.Optional(CONF_OBJECT_AREA)
)
install_date_key = (
vol.Optional(CONF_OBJECT_INSTALLATION_DATE, default=obj.get(CONF_OBJECT_INSTALLATION_DATE))
if obj.get(CONF_OBJECT_INSTALLATION_DATE)
else vol.Optional(CONF_OBJECT_INSTALLATION_DATE)
)
warranty_key = (
vol.Optional(CONF_OBJECT_WARRANTY_EXPIRY, default=obj.get(CONF_OBJECT_WARRANTY_EXPIRY))
if obj.get(CONF_OBJECT_WARRANTY_EXPIRY)
else vol.Optional(CONF_OBJECT_WARRANTY_EXPIRY)
)
return self.async_show_form(
step_id="object_settings",
data_schema=vol.Schema(
{
vol.Required(CONF_OBJECT_NAME, default=obj.get("name", "")): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional(
CONF_OBJECT_MANUFACTURER,
default=obj.get("manufacturer", ""),
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
vol.Optional(CONF_OBJECT_MODEL, default=obj.get("model", "")): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional(
CONF_OBJECT_SERIAL_NUMBER,
default=obj.get("serial_number") or "",
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)),
# v1.4.0 (#43): place under serial_number
vol.Optional(
CONF_OBJECT_DOCUMENTATION_URL,
default=obj.get("documentation_url") or "",
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.URL)),
# v1.4.10 (#46): free-form notes (multiline)
vol.Optional(
CONF_OBJECT_NOTES,
default=obj.get("notes") or "",
): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
multiline=True,
)
),
area_key: selector.AreaSelector(),
install_date_key: selector.DateSelector(),
warranty_key: selector.DateSelector(),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
)
@@ -0,0 +1,437 @@
"""Trigger edit / summary / remove steps + opt_* wrappers (mixin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .config_flow_trigger import TriggerConfigMixin
from .const import (
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_WARNING_DAYS,
CONF_TASKS,
ScheduleType,
TriggerType,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
class TriggerStepsMixin(TriggerConfigMixin):
"""Sensor-trigger editing steps; opt_* wrappers delegate to the
shared TriggerConfigMixin. Owns the compound step aliases."""
# -- provided by the assembled MaintenanceOptionsFlow --
if TYPE_CHECKING:
config_entry: ConfigEntry
_selected_task_id: str | None
def _show_task_action_menu(self) -> ConfigFlowResult: ...
def _update_config_entry(self, new_data: dict[str, Any]) -> None: ...
def async_show_menu(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_edit_trigger(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Edit the trigger configuration for an existing task."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
if task.get("trigger_config"):
return await self.async_step_trigger_summary()
# No existing trigger — go directly to sensor select
self._current_task = {}
self._trigger_on_complete = self._save_edited_trigger
self._on_cancel = self._show_task_action_menu
return await self.async_step_opt_sensor_select()
@staticmethod
def _condition_summary(cond: dict[str, Any]) -> str:
"""Build a short summary string for a single trigger condition."""
ctype = cond.get("type", "?")
parts: list[str] = []
if ctype == TriggerType.THRESHOLD:
if cond.get("trigger_above") is not None:
parts.append(f"above: {cond['trigger_above']}")
if cond.get("trigger_below") is not None:
parts.append(f"below: {cond['trigger_below']}")
if cond.get("trigger_for_minutes"):
parts.append(f"for: {cond['trigger_for_minutes']}min")
elif ctype == TriggerType.COUNTER:
if cond.get("trigger_target_value") is not None:
parts.append(f"target: {cond['trigger_target_value']}")
if cond.get("trigger_delta_mode"):
parts.append("delta mode")
elif ctype == TriggerType.STATE_CHANGE:
if cond.get("trigger_target_changes") is not None:
parts.append(f"changes: {cond['trigger_target_changes']}")
if cond.get("trigger_from_state"):
parts.append(f"from: {cond['trigger_from_state']}")
if cond.get("trigger_to_state"):
parts.append(f"to: {cond['trigger_to_state']}")
elif ctype == TriggerType.RUNTIME:
if cond.get("trigger_runtime_hours") is not None:
parts.append(f"hours: {cond['trigger_runtime_hours']}")
return ", ".join(parts) if parts else ""
@staticmethod
def _get_entity_ids_str(tc: dict[str, Any]) -> str:
"""Get display string for entity IDs from trigger config."""
entity_ids = tc.get("entity_ids", [])
if not entity_ids:
eid = tc.get("entity_id", "")
entity_ids = [eid] if isinstance(eid, str) and eid else (eid if isinstance(eid, list) else [])
return ", ".join(entity_ids) if entity_ids else ""
@staticmethod
def _build_trigger_config_parts(tc: dict[str, Any]) -> list[str]:
"""Build config detail parts for a trigger config (shared by summary & remove)."""
trigger_type = tc.get("type", "unknown")
config_parts: list[str] = []
if trigger_type == TriggerType.THRESHOLD:
if tc.get("trigger_above") is not None:
config_parts.append(f"above: {tc['trigger_above']}")
if tc.get("trigger_below") is not None:
config_parts.append(f"below: {tc['trigger_below']}")
if tc.get("trigger_for_minutes"):
config_parts.append(f"for: {tc['trigger_for_minutes']}min")
elif trigger_type == TriggerType.COUNTER:
if tc.get("trigger_target_value") is not None:
config_parts.append(f"target: {tc['trigger_target_value']}")
if tc.get("trigger_delta_mode"):
config_parts.append("delta mode")
elif trigger_type == TriggerType.STATE_CHANGE:
if tc.get("trigger_target_changes") is not None:
config_parts.append(f"changes: {tc['trigger_target_changes']}")
if tc.get("trigger_from_state"):
config_parts.append(f"from: {tc['trigger_from_state']}")
if tc.get("trigger_to_state"):
config_parts.append(f"to: {tc['trigger_to_state']}")
elif trigger_type == TriggerType.RUNTIME:
if tc.get("trigger_runtime_hours") is not None:
config_parts.append(f"hours: {tc['trigger_runtime_hours']}")
elif trigger_type == TriggerType.COMPOUND:
conditions = tc.get("conditions", [])
logic = tc.get("compound_logic", "AND")
config_parts.append(f"logic: {logic}")
for i, cond in enumerate(conditions, 1):
ctype = cond.get("type", "?")
c_eids = cond.get("entity_ids", [])
if not c_eids:
c_eid = cond.get("entity_id", "?")
c_eids = [c_eid] if isinstance(c_eid, str) else c_eid
c_entities = ", ".join(c_eids[:2])
c_detail = TriggerStepsMixin._condition_summary(cond)
config_parts.append(f"#{i} {ctype}: {c_entities} ({c_detail})")
return config_parts
async def async_step_trigger_summary(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Show current trigger configuration summary before editing."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
tc = task.get("trigger_config", {})
entity_ids_str = self._get_entity_ids_str(tc)
# Current state values
state_parts: list[str] = []
eid_list = tc.get("entity_ids", tc.get("entity_id", []))
if isinstance(eid_list, str):
eid_list = [eid_list]
for eid in eid_list[:3]:
state = self.hass.states.get(eid)
if state:
state_parts.append(f"{eid}: {state.state}")
else:
state_parts.append(f"{eid}: unavailable")
current_states = ", ".join(state_parts) if state_parts else ""
trigger_type = tc.get("type", "unknown")
attribute = tc.get("attribute") or "state"
config_parts = self._build_trigger_config_parts(tc)
config_details = "\n".join(config_parts) if config_parts else ""
return self.async_show_menu(
step_id="trigger_summary",
menu_options=["edit_trigger_proceed", "task_action"],
description_placeholders={
"task_name": task.get("name", ""),
"entity_ids": entity_ids_str,
"current_states": current_states,
"trigger_type": trigger_type,
"attribute": attribute,
"config_details": config_details,
},
)
async def async_step_edit_trigger_proceed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Proceed with editing the trigger after reviewing the summary."""
self._current_task = {}
self._trigger_on_complete = self._save_edited_trigger
self._on_cancel = self._show_task_action_menu
return await self.async_step_opt_sensor_select()
def _save_edited_trigger(self) -> ConfigFlowResult:
"""Save edited trigger configuration to an existing task."""
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
if "trigger_config" in self._current_task:
updated_task["trigger_config"] = self._current_task["trigger_config"]
if CONF_TASK_SCHEDULE_TYPE in self._current_task:
updated_task["schedule_type"] = self._current_task[CONF_TASK_SCHEDULE_TYPE]
if CONF_TASK_INTERVAL_DAYS in self._current_task:
updated_task["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
if CONF_TASK_WARNING_DAYS in self._current_task:
updated_task["warning_days"] = int(self._current_task[CONF_TASK_WARNING_DAYS])
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
self._current_task = {}
return self._show_task_action_menu()
async def async_step_remove_trigger(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Confirm and remove trigger configuration from a task."""
tasks_data = self.config_entry.data.get(CONF_TASKS, {})
task = tasks_data.get(self._selected_task_id or "", {})
tc = task.get("trigger_config", {})
# Resolve entity list
entity_ids = tc.get("entity_ids", [])
if not entity_ids:
eid = tc.get("entity_id", "")
entity_ids = [eid] if isinstance(eid, str) and eid else (eid if isinstance(eid, list) else [])
has_multiple = len(entity_ids) > 1
if user_input is not None:
if user_input.get("go_back"):
return self._show_task_action_menu()
if user_input.get("confirm"):
entities_to_remove = user_input.get("entities_to_remove", entity_ids)
remaining = [e for e in entity_ids if e not in entities_to_remove]
new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
if remaining:
# Partial removal — keep trigger with remaining entities
updated_tc = dict(updated_task.get("trigger_config", {}))
updated_tc["entity_ids"] = remaining
updated_tc.pop("entity_id", None)
updated_task["trigger_config"] = updated_tc
else:
# Full removal — remove entire trigger config
updated_task.pop("trigger_config", None)
if updated_task.get("schedule_type") == ScheduleType.SENSOR_BASED:
updated_task["schedule_type"] = ScheduleType.TIME_BASED
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
self._update_config_entry(new_data)
return self._show_task_action_menu()
# Build rich description from trigger_config
entity_ids_str = self._get_entity_ids_str(tc)
trigger_type = tc.get("type", "unknown")
config_parts = self._build_trigger_config_parts(tc)
config_details = "\n".join(config_parts) if config_parts else ""
# Build schema — add entity selector for multi-entity triggers
schema_dict: dict[Any, Any] = {}
if has_multiple:
schema_dict[vol.Required("entities_to_remove")] = selector.EntitySelector(
selector.EntitySelectorConfig(
include_entities=entity_ids,
multiple=True,
)
)
schema_dict[vol.Required("confirm", default=False)] = selector.BooleanSelector()
schema_dict[vol.Optional("go_back", default=False)] = selector.BooleanSelector()
return self.async_show_form(
step_id="remove_trigger",
data_schema=vol.Schema(schema_dict),
description_placeholders={
"task_name": task.get("name", ""),
"entity_ids": entity_ids_str,
"trigger_type": trigger_type,
"config_details": config_details,
},
)
async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select sensor entity for trigger."""
# Pre-populate with existing entity_ids when editing a trigger
existing = None
if self._selected_task_id:
tasks = self.config_entry.data.get(CONF_TASKS, {})
task = tasks.get(self._selected_task_id, {})
tc = task.get("trigger_config", {})
eids = tc.get("entity_ids", [])
if not eids:
eid = tc.get("entity_id", "")
eids = [eid] if eid else []
if eids:
existing = eids
return await self._trigger_sensor_select(
user_input,
step_id="opt_sensor_select",
next_step=self.async_step_opt_sensor_attribute,
default_entities=existing,
)
async def async_step_opt_sensor_attribute(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select attribute to monitor."""
return await self._trigger_sensor_attribute(
user_input,
step_id="opt_sensor_attribute",
next_step=self.async_step_opt_trigger_type,
error_step_id="opt_sensor_select",
)
async def async_step_opt_trigger_type(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select trigger type."""
return await self._trigger_type_select(
user_input,
step_id="opt_trigger_type",
threshold_step=self.async_step_opt_trigger_threshold,
counter_step=self.async_step_opt_trigger_counter,
state_change_step=self.async_step_opt_trigger_state_change,
runtime_step=self.async_step_opt_trigger_runtime,
compound_step=self.async_step_opt_compound_logic,
)
async def async_step_opt_trigger_threshold(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure threshold trigger."""
return await self._trigger_threshold_config(
user_input,
step_id="opt_trigger_threshold",
on_complete=self._trigger_on_complete,
)
async def async_step_opt_trigger_counter(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure counter trigger."""
return await self._trigger_counter_config(
user_input,
step_id="opt_trigger_counter",
on_complete=self._trigger_on_complete,
)
async def async_step_opt_trigger_state_change(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure state change trigger."""
return await self._trigger_state_change_config(
user_input,
step_id="opt_trigger_state_change",
on_complete=self._trigger_on_complete,
)
async def async_step_opt_trigger_runtime(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure runtime trigger."""
return await self._trigger_runtime_config(
user_input,
step_id="opt_trigger_runtime",
on_complete=self._trigger_on_complete,
)
async def async_step_opt_compound_logic(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select compound trigger logic."""
return await self._trigger_compound_logic(
user_input,
step_id="compound_logic",
next_step=self.async_step_opt_compound_condition_entity,
)
async def async_step_opt_compound_condition_entity(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select entity for compound condition."""
return await self._trigger_compound_condition_entity(
user_input,
step_id="compound_condition_entity",
next_step=self.async_step_opt_compound_condition_type,
)
async def async_step_opt_compound_condition_type(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Select trigger type for compound condition."""
return await self._trigger_compound_condition_type(
user_input,
step_id="compound_condition_type",
threshold_step=self.async_step_opt_compound_condition_threshold,
counter_step=self.async_step_opt_compound_condition_counter,
state_change_step=self.async_step_opt_compound_condition_state_change,
runtime_step=self.async_step_opt_compound_condition_runtime,
)
async def async_step_opt_compound_condition_threshold(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure threshold for compound condition."""
return await self._trigger_compound_condition_config(
user_input,
"threshold",
step_id="compound_condition_threshold",
on_complete=self.async_step_opt_compound_review,
)
async def async_step_opt_compound_condition_counter(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure counter for compound condition."""
return await self._trigger_compound_condition_config(
user_input,
"counter",
step_id="compound_condition_counter",
on_complete=self.async_step_opt_compound_review,
)
async def async_step_opt_compound_condition_state_change(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure state_change for compound condition."""
return await self._trigger_compound_condition_config(
user_input,
"state_change",
step_id="compound_condition_state_change",
on_complete=self.async_step_opt_compound_review,
)
async def async_step_opt_compound_condition_runtime(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure runtime for compound condition."""
return await self._trigger_compound_condition_config(
user_input,
"runtime",
step_id="compound_condition_runtime",
on_complete=self.async_step_opt_compound_review,
)
async def async_step_opt_compound_review(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Review compound trigger conditions."""
return await self._trigger_compound_review(
user_input,
step_id="compound_review",
add_condition_step=self.async_step_opt_compound_condition_entity,
on_complete=self._trigger_on_complete,
)
# Compound step aliases — HA routes by async_step_<step_id>;
# these expose the opt_* compound steps under their bare names.
async_step_compound_logic = async_step_opt_compound_logic
async_step_compound_condition_entity = async_step_opt_compound_condition_entity
async_step_compound_condition_type = async_step_opt_compound_condition_type
async_step_compound_condition_threshold = async_step_opt_compound_condition_threshold
async_step_compound_condition_counter = async_step_opt_compound_condition_counter
async_step_compound_condition_state_change = async_step_opt_compound_condition_state_change
async_step_compound_condition_runtime = async_step_opt_compound_condition_runtime
async_step_compound_review = async_step_opt_compound_review
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,577 @@
"""Constants for the Maintenance Supporter integration."""
from __future__ import annotations
import re
from enum import StrEnum
from homeassistant.const import Platform
DOMAIN = "maintenance_supporter"
def slugify_object_name(name: str) -> str:
"""Convert an object name to a safe slug for use in unique IDs.
Replaces any non-alphanumeric character with underscore, collapses
consecutive underscores, and strips leading/trailing underscores.
"""
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]", "_", name.lower())).strip("_")
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
Platform.TODO,
]
# --- Unique IDs ---
GLOBAL_UNIQUE_ID = "maintenance_supporter_global"
# --- Defaults ---
DEFAULT_WARNING_DAYS = 7
DEFAULT_INTERVAL_DAYS = 30
DEFAULT_UPDATE_INTERVAL_MINUTES = 5
DEFAULT_MAX_HISTORY_ENTRIES = 500
DEFAULT_SNOOZE_DURATION_HOURS = 4
DEFAULT_MAX_NOTIFICATIONS_PER_DAY = 0 # 0 = unlimited
# --- Adaptive Scheduling Defaults ---
DEFAULT_ADAPTIVE_EWA_ALPHA = 0.3
DEFAULT_ADAPTIVE_MIN_INTERVAL = 7
DEFAULT_ADAPTIVE_MAX_INTERVAL = 365
DEFAULT_ADAPTIVE_MIN_COMPLETIONS = 3 # Before showing suggestions
DEFAULT_ADAPTIVE_WEIBULL_MIN = 5 # Before fitting Weibull
DEFAULT_ADAPTIVE_RELIABILITY_TARGET = 0.9 # 90% for Weibull
# --- Seasonal Scheduling Defaults ---
DEFAULT_SEASONAL_MIN_DATA = 6 # Min intervals across all months before seasonal adjustment
DEFAULT_SEASONAL_FACTOR_MIN = 0.3 # Floor: never less than 30% of base interval
DEFAULT_SEASONAL_FACTOR_MAX = 3.0 # Ceiling: never more than 300% of base interval
# --- Hemisphere-aware Season Mapping ---
NORTHERN_SEASONS: dict[str, list[int]] = {
"spring": [3, 4, 5],
"summer": [6, 7, 8],
"fall": [9, 10, 11],
"winter": [12, 1, 2],
}
SOUTHERN_SEASONS: dict[str, list[int]] = {
"spring": [9, 10, 11],
"summer": [12, 1, 2],
"fall": [3, 4, 5],
"winter": [6, 7, 8],
}
# --- Config Keys: Global ---
CONF_DEFAULT_WARNING_DAYS = "default_warning_days"
CONF_NOTIFICATIONS_ENABLED = "notifications_enabled"
CONF_NOTIFY_SERVICE = "notify_service"
CONF_PANEL_ENABLED = "panel_enabled"
# v2.10.4 (#69 follow-up): the sidebar panel is the integration's hub — the
# auto-dashboard's "Open Maintenance panel" button, QR codes, and notifications
# all link to /maintenance-supporter. With the panel off those links 404, so it
# now defaults ON. An explicit opt-out (panel_enabled: false in options) is
# still honoured. Read this default everywhere instead of inlining the literal.
DEFAULT_PANEL_ENABLED = True
CONF_PANEL_TITLE = "panel_title"
# --- Config Keys: Advanced Feature Visibility ---
CONF_ADVANCED_ADAPTIVE = "advanced_adaptive_visible"
CONF_ADVANCED_PREDICTIONS = "advanced_predictions_visible"
CONF_ADVANCED_SEASONAL = "advanced_seasonal_visible"
CONF_ADVANCED_ENVIRONMENTAL = "advanced_environmental_visible"
CONF_ADVANCED_BUDGET = "advanced_budget_visible"
CONF_ADVANCED_GROUPS = "advanced_groups_visible"
CONF_ADVANCED_CHECKLISTS = "advanced_checklists_visible"
CONF_ADVANCED_SCHEDULE_TIME = "advanced_schedule_time_visible"
# v1.3.0: gates the per-task on_complete_action + quick_complete_defaults
# UI sections in the task dialog, plus the quick_complete chip in the
# Print-QR generator. Default OFF — beginners aren't overwhelmed; data
# still persists if a user toggles the flag off again.
CONF_ADVANCED_COMPLETION_ACTIONS = "advanced_completion_actions_visible"
# Panel-access overrides: HA user IDs who get the full admin panel despite
# not being HA admins. Empty list means only admins see the full panel.
# The allowlist grants create/edit/delete ONLY when CONF_OPERATOR_WRITE_ENABLED
# is also on; with it off (the default) listed users get read-only access.
CONF_ADMIN_PANEL_USER_IDS = "admin_panel_user_ids"
# v2.8.4: master switch for operator write delegation. Default OFF → only HA
# admins may create/edit/delete and the panel-access allowlist is read-only.
# When True, allowlisted non-admins additionally gain full content CRUD. Only
# admins can flip it (panel Settings tab / config-flow Panel Access step, both
# admin-gated), preserving the escalation boundary in helpers/permissions.py.
CONF_OPERATOR_WRITE_ENABLED = "operator_write_enabled"
# (#67): objects-table column configuration for the panel All-Objects view
# (table mode). Global setting holding the ordered list of object columns to
# show. Selectable from KNOWN_OBJECT_TABLE_COLUMNS only — known object fields,
# never arbitrary state attributes.
CONF_OBJECTS_TABLE_COLUMNS = "objects_table_columns"
# v2.21: template-gallery curation — ids of built-in templates the admin has
# hidden from the "From template" pickers (panel gallery + config flow). The
# templates stay functional (direct WS calls still work); they are only
# removed from the pickers so a growing catalog never clutters the UI.
CONF_DISABLED_TEMPLATE_IDS = "disabled_template_ids"
# Every column key the objects table can render. The Settings UI offers exactly
# these; the WS update handler drops anything outside this set.
KNOWN_OBJECT_TABLE_COLUMNS = [
"name",
"manufacturer",
"model",
"serial_number",
"installation_date",
"warranty_expiry",
"area_id",
"documentation_url",
"notes",
"task_count",
"actions",
]
# Default column set + order when the user hasn't customised it. Mirrors the
# locked design: Name · Manufacturer · Model · Serial · Installed · Warranty ·
# Area · Tasks · link (documentation_url + notes are opt-in extras).
DEFAULT_OBJECTS_TABLE_COLUMNS = [
"name",
"manufacturer",
"model",
"serial_number",
"installation_date",
"warranty_expiry",
"area_id",
"task_count",
"actions",
]
# --- Vacation mode (v1.2.0) ---
# When active, suppresses notifications for non-exempt tasks across the
# vacation window plus an N-day buffer (so a task that comes due the day
# of return doesn't immediately fire).
CONF_VACATION_ENABLED = "vacation_enabled"
CONF_VACATION_START = "vacation_start" # ISO date "YYYY-MM-DD"
CONF_VACATION_END = "vacation_end" # ISO date
CONF_VACATION_BUFFER_DAYS = "vacation_buffer_days"
CONF_VACATION_EXEMPT_TASK_IDS = "vacation_exempt_task_ids"
DEFAULT_VACATION_BUFFER_DAYS = 3
# --- Config Keys: Notification Per-Status ---
CONF_NOTIFY_DUE_SOON_ENABLED = "notify_due_soon_enabled"
CONF_NOTIFY_DUE_SOON_INTERVAL = "notify_due_soon_interval_hours"
CONF_NOTIFY_OVERDUE_ENABLED = "notify_overdue_enabled"
CONF_NOTIFY_OVERDUE_INTERVAL = "notify_overdue_interval_hours"
CONF_NOTIFY_TRIGGERED_ENABLED = "notify_triggered_enabled"
CONF_NOTIFY_TRIGGERED_INTERVAL = "notify_triggered_interval_hours"
# --- Config Keys: Notification Quiet Hours ---
CONF_QUIET_HOURS_ENABLED = "quiet_hours_enabled"
CONF_QUIET_HOURS_START = "quiet_hours_start"
CONF_QUIET_HOURS_END = "quiet_hours_end"
# Shared HH:MM or HH:MM:SS time pattern (023 h, 059 m/s) — the single source
# for validating quiet-hours times in BOTH the options flow and the WS handler.
TIME_HHMMSS_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$")
# --- Config Keys: Notification Limits ---
CONF_MAX_NOTIFICATIONS_PER_DAY = "max_notifications_per_day"
# --- Config Keys: Notification Bundling ---
CONF_NOTIFICATION_BUNDLING_ENABLED = "notification_bundling_enabled"
CONF_NOTIFICATION_BUNDLE_THRESHOLD = "notification_bundle_threshold"
# --- Config Keys: Notification Title (v1.4.0 #44) ---
# How to format the notification title.
# "default" — generic per-status title (e.g. "Maintenance overdue!"). Backwards-compatible.
# "object_name" — use the object name as the title (helps when phone stacks notifications).
# "task_name" — use the task name as the title.
CONF_NOTIFICATION_TITLE_STYLE = "notification_title_style"
NOTIFICATION_TITLE_STYLES = ("default", "object_name", "task_name")
# --- Config Keys: Notification Actions ---
CONF_ACTION_COMPLETE_ENABLED = "action_complete_enabled"
CONF_ACTION_SKIP_ENABLED = "action_skip_enabled"
CONF_ACTION_SNOOZE_ENABLED = "action_snooze_enabled"
CONF_SNOOZE_DURATION_HOURS = "snooze_duration_hours"
# v2.15.0: opt-in weekly digest — a single Monday-morning summary notification.
CONF_WEEKLY_DIGEST_ENABLED = "weekly_digest_enabled"
CONF_WARRANTY_REMINDER_ENABLED = "warranty_reminder_enabled"
CONF_WARRANTY_REMINDER_DAYS = "warranty_reminder_days"
DEFAULT_WARRANTY_REMINDER_DAYS = 30
# Multiple lead-time reminders: list of days-before-due at which an extra
# reminder fires (e.g. [14, 3, 0]). Empty = feature off (single warning_days
# threshold behaviour unchanged). Checked by a daily tick, not the
# status-change path.
CONF_REMINDER_LEAD_DAYS = "reminder_lead_days"
MAX_REMINDER_LEADS = 10
# --- Panel ---
PANEL_NAME = "maintenance-supporter"
PANEL_TITLE = "Maintenance"
PANEL_ICON = "mdi:wrench-clock"
PANEL_URL = "/maintenance_supporter_panel"
CARD_URL = "/maintenance_supporter_card"
# v2.3.4 (issue #52): strategy is now a multi-file split bundle. The entry
# URL has to live UNDER a directory prefix so the entry's relative
# ./chunks/X.js dynamic imports resolve to /<prefix>/chunks/X.js. Pre-2.3.4
# users had a flat URL — old dashboards still load fine because the strategy
# is identified by ``custom:maintenance-supporter`` (a logical name resolved
# via window.customStrategies), not by URL.
STRATEGY_DIR_URL = "/maintenance_supporter_strategy"
STRATEGY_URL = f"{STRATEGY_DIR_URL}/maintenance-dashboard-strategy.js"
STRATEGY_CHUNKS_URL = f"{STRATEGY_DIR_URL}/chunks"
# v2.8.1 (strategy timeout fix): a tiny zero-import shim is what we register as
# the frontend extra-module-url. It defines the dashboard-strategy element
# synchronously (winning HA's 5 s whenDefined race under heavy plugin load) and
# lazy-imports the full STRATEGY_URL bundle on first use. The bundle's own
# relative ./chunks/* imports still resolve because it is served under
# STRATEGY_DIR_URL. The shim has no relative imports, so a flat URL is fine.
STRATEGY_SHIM_URL = "/maintenance_supporter_strategy_shim.js"
CALENDAR_CARD_URL = "/maintenance_supporter_calendar_card"
# Runtime-loaded UI translations (frontend/locales/<lang>.json), served as a
# directory so the panel/card fetch the active language on demand. Mirrors
# LOCALES_BASE in frontend-src/styles.ts: only EN is bundled into the JS (as the
# fallback); the other languages live here, so a translation edit needs no
# frontend bundle rebuild.
LOCALES_URL = "/maintenance_supporter_locales"
# v2.21: vendored third-party assets (pdf.js for the work sheet's inline
# manual excerpt) — served statically, loaded ONLY by the work-sheet tab so
# the panel bundle stays lean.
VENDOR_URL = "/maintenance_supporter_vendor"
# --- Config Keys: Object ---
CONF_OBJECT = "object"
CONF_OBJECT_NAME = "name"
CONF_OBJECT_AREA = "area_id"
CONF_OBJECT_MANUFACTURER = "manufacturer"
CONF_OBJECT_MODEL = "model"
CONF_OBJECT_SERIAL_NUMBER = "serial_number"
CONF_OBJECT_INSTALLATION_DATE = "installation_date"
# (#67): per-object warranty expiry date (ISO YYYY-MM-DD) for asset tracking
CONF_OBJECT_WARRANTY_EXPIRY = "warranty_expiry"
# v1.4.0 (#43): per-object link to PDF manual / vendor page
CONF_OBJECT_DOCUMENTATION_URL = "documentation_url"
# v1.4.10 (#46): per-object free-form notes (part numbers, procedures, etc.)
CONF_OBJECT_NOTES = "notes"
# --- Config Keys: Task ---
CONF_TASKS = "tasks"
CONF_TASK_NAME = "name"
CONF_TASK_TYPE = "type"
CONF_TASK_ENABLED = "enabled"
CONF_TASK_INTERVAL_DAYS = "interval_days"
CONF_TASK_WARNING_DAYS = "warning_days"
CONF_TASK_LAST_PERFORMED = "last_performed"
CONF_TASK_SCHEDULE_TYPE = "schedule_type"
CONF_TASK_INTERVAL_UNIT = "interval_unit"
CONF_TASK_DUE_DATE = "due_date"
CONF_TASK_NOTES = "notes"
CONF_TASK_DOCUMENTATION_URL = "documentation_url"
CONF_TASK_ICON = "custom_icon"
CONF_TASK_NFC_TAG = "nfc_tag_id"
# v2.20 (#83): display unit for `reading`-type tasks ("kWh", "m³", ...).
CONF_TASK_READING_UNIT = "reading_unit"
MAX_READING_UNIT_LENGTH = 32
CONF_TASK_INTERVAL_ANCHOR = "interval_anchor"
CONF_TASK_SCHEDULE_TIME = "schedule_time"
CONF_TASK_PRIORITY = "priority"
# Config-flow-only field: comma-separated labels text, parsed into the
# persisted ``labels`` list by the task-base handler.
CONF_TASK_LABELS_TEXT = "labels_text"
CONF_TASK_ASSIGNEE_POOL = "assignee_pool"
CONF_TASK_ROTATION_STRATEGY = "rotation_strategy"
# Rotation strategies for shared tasks: advance the "currently responsible"
# pointer on each completion. None/"" = no rotation (single-assignee behaviour).
ROTATION_STRATEGIES = ("round_robin", "least_completed", "random")
MAX_ASSIGNEE_POOL = 25
# --- Config Keys: User Assignment ---
CONF_RESPONSIBLE_USER_ID = "responsible_user_id"
# --- Config Keys: Trigger ---
CONF_TRIGGER_CONFIG = "trigger_config"
CONF_TRIGGER_TYPE = "trigger_type"
CONF_TRIGGER_ENTITY = "trigger_entity"
CONF_TRIGGER_ENTITY_IDS = "entity_ids"
CONF_TRIGGER_ENTITY_LOGIC = "entity_logic"
CONF_TRIGGER_STATE = "_trigger_state"
CONF_COMPOUND_LOGIC = "compound_logic"
CONF_COMPOUND_CONDITIONS = "conditions"
DEFAULT_ENTITY_LOGIC = "any"
CONF_TRIGGER_ATTRIBUTE = "trigger_attribute"
CONF_TRIGGER_ABOVE = "trigger_above"
CONF_TRIGGER_BELOW = "trigger_below"
CONF_TRIGGER_FOR_MINUTES = "trigger_for_minutes"
CONF_TRIGGER_TARGET_VALUE = "trigger_target_value"
CONF_TRIGGER_DELTA_MODE = "trigger_delta_mode"
CONF_TRIGGER_BASELINE_VALUE = "trigger_baseline_value"
CONF_TRIGGER_FROM_STATE = "trigger_from_state"
CONF_TRIGGER_TO_STATE = "trigger_to_state"
CONF_TRIGGER_TARGET_CHANGES = "trigger_target_changes"
CONF_TRIGGER_RUNTIME_HOURS = "trigger_runtime_hours"
CONF_TRIGGER_ON_STATES = "trigger_on_states"
# --- Config Keys: Adaptive Scheduling ---
CONF_ADAPTIVE_CONFIG = "adaptive_config"
CONF_ADAPTIVE_ENABLED = "adaptive_enabled"
CONF_ADAPTIVE_EWA_ALPHA = "ewa_alpha"
CONF_ADAPTIVE_MIN_INTERVAL = "min_interval_days"
CONF_ADAPTIVE_MAX_INTERVAL = "max_interval_days"
# --- Config Keys: Seasonal Scheduling ---
CONF_SEASONAL_ENABLED = "seasonal_enabled"
CONF_SEASONAL_OVERRIDES = "seasonal_overrides"
CONF_SEASONAL_HEMISPHERE = "seasonal_hemisphere"
# --- Sensor Prediction Defaults (Phase 3) ---
DEFAULT_DEGRADATION_LOOKBACK_DAYS = 30 # Days of recorder data for slope computation
DEFAULT_DEGRADATION_MIN_POINTS = 10 # Min hourly data points to compute regression
DEFAULT_DEGRADATION_SIGNIFICANCE = 0.05 # Min |slope|/mean ratio for rising/falling
# --- Environmental Adjustment Defaults (Phase 3) ---
DEFAULT_ENVIRONMENTAL_LOOKBACK_DAYS = 90 # Days of env data for correlation
DEFAULT_ENVIRONMENTAL_CORRELATION_MIN = 0.3 # Min |r| to apply adjustment
DEFAULT_ENVIRONMENTAL_FACTOR_MIN = 0.5 # Floor for env adjustment
DEFAULT_ENVIRONMENTAL_FACTOR_MAX = 2.0 # Ceiling for env adjustment
DEFAULT_ENVIRONMENTAL_MIN_COMPLETIONS = 3 # Min completions with env data
# --- Config Keys: Sensor Prediction (Phase 3) ---
CONF_SENSOR_PREDICTION_ENABLED = "sensor_prediction_enabled"
CONF_ENVIRONMENTAL_ENTITY = "environmental_entity"
CONF_ENVIRONMENTAL_ATTRIBUTE = "environmental_attribute"
# --- Budget ---
CONF_BUDGET_MONTHLY = "budget_monthly"
CONF_BUDGET_YEARLY = "budget_yearly"
CONF_BUDGET_ALERTS_ENABLED = "budget_alerts_enabled"
CONF_BUDGET_ALERT_THRESHOLD = "budget_alert_threshold"
CONF_BUDGET_CURRENCY = "budget_currency"
BUDGET_CURRENCIES: dict[str, str] = {
"EUR": "",
"USD": "$",
"GBP": "£",
"JPY": "¥",
"CHF": "Fr",
"CAD": "C$",
"AUD": "A$",
"CNY": "¥",
"INR": "",
"BRL": "R$",
"CZK": "",
"PLN": "",
"RUB": "",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"UAH": "",
}
# Default currency when the user hasn't chosen one. Its symbol is derived from
# BUDGET_CURRENCIES so the two never disagree.
DEFAULT_BUDGET_CURRENCY = "EUR"
# --- Archive & Retention (v2.10.0) ---
# Global int settings (panel-managed via WS, like objects_table_columns):
# archive_oneoff_days — auto-archive a completed one-off this many days
# after completion. 0 = disabled (manual only).
# delete_archived_oneoff_days — auto-delete an AUTO-archived one-off this many
# days after it was archived. 0 = never. Manual
# archives are NEVER auto-deleted (delete stays explicit).
CONF_ARCHIVE_ONEOFF_DAYS = "archive_oneoff_days"
CONF_DELETE_ARCHIVED_ONEOFF_DAYS = "delete_archived_oneoff_days"
DEFAULT_ARCHIVE_ONEOFF_DAYS = 14
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS = 0 # 0 = never
# Per-task/object `archived_reason` values (stored alongside `archived_at`).
# Drives two policies: only AUTO is eligible for auto-delete; only OBJECT is
# undone by an object unarchive cascade (MANUAL/AUTO survive it).
ARCHIVE_REASON_MANUAL = "manual"
ARCHIVE_REASON_AUTO = "auto"
ARCHIVE_REASON_OBJECT = "object"
# --- Groups ---
CONF_GROUPS = "groups"
# --- Checklist ---
CONF_CHECKLIST = "checklist"
# --- History ---
CONF_HISTORY = "history"
# --- Runtime keys (not persisted) ---
RUNTIME_TRIGGER_ACTIVE = "_trigger_active"
RUNTIME_TRIGGER_CURRENT_VALUE = "_trigger_current_value"
RUNTIME_TRIGGER_CHANGE_COUNT = "_trigger_change_count"
RUNTIME_TRIGGER_CURRENT_DELTA = "_trigger_current_delta"
# --- Event Types ---
EVENT_TRIGGER_ACTIVATED = f"{DOMAIN}_trigger_activated"
EVENT_TRIGGER_DEACTIVATED = f"{DOMAIN}_trigger_deactivated"
# v1.3.0: completion lifecycle events. Fired by coordinator after the
# state mutation has persisted. Power users wire HA automations on these;
# the integration's own action_listener also subscribes to the COMPLETED
# event to dispatch the per-task on_complete_action service-call.
EVENT_TASK_COMPLETED = f"{DOMAIN}_task_completed"
EVENT_TASK_SKIPPED = f"{DOMAIN}_task_skipped"
EVENT_TASK_RESET = f"{DOMAIN}_task_reset"
# --- Service Names ---
SERVICE_COMPLETE = "complete"
SERVICE_RESET = "reset"
SERVICE_SKIP = "skip"
SERVICE_EXPORT = "export_data"
SERVICE_ADD_OBJECT = "add_object"
SERVICE_ADD_TASK = "add_task"
SERVICE_UPDATE_TASK = "update_task"
SERVICE_DELETE_TASK = "delete_task"
SERVICE_LIST_TASKS = "list_tasks"
class MaintenanceStatus(StrEnum):
"""Status of a maintenance task."""
OK = "ok"
DUE_SOON = "due_soon"
OVERDUE = "overdue"
TRIGGERED = "triggered"
# v2.10.0: highest-precedence status. An archived task/object is retired but
# retained — it reads `archived`, fires nothing, and counts for nothing
# except budget/cost history (which is read from completion history, not
# status, so it is archive-agnostic by design).
ARCHIVED = "archived"
# v2.20 (journey N3): seasonal pause. Tasks of a paused OBJECT read
# `paused`: schedules freeze and nothing fires — but unlike archived the
# object stays a first-class citizen in every view. Resuming re-anchors
# recurring tasks like an object unarchive does. Object-wide only (no
# per-task pause); injected by the coordinator, below ARCHIVED in
# precedence.
PAUSED = "paused"
class MaintenanceTypeEnum(StrEnum):
"""Type/category of maintenance."""
CLEANING = "cleaning"
INSPECTION = "inspection"
REPLACEMENT = "replacement"
CALIBRATION = "calibration"
SERVICE = "service"
# Record a value / take a reading (meter readings, level checks, spot
# measurements) — requested in Discussion #83, generalised beyond meters.
READING = "reading"
CUSTOM = "custom"
class ScheduleType(StrEnum):
"""How a task is scheduled."""
TIME_BASED = "time_based"
SENSOR_BASED = "sensor_based"
MANUAL = "manual"
ONE_TIME = "one_time"
# Schedule kinds expressed by the FLAT recurrence fields (interval/due_date),
# as opposed to the nested calendar kinds (weekdays/nth_weekday/day_of_month)
# or a sensor trigger. Used to decide flat-vs-calendar handling on task edit.
FLAT_SCHEDULE_TYPES = frozenset({ScheduleType.TIME_BASED, ScheduleType.ONE_TIME, ScheduleType.MANUAL})
class TriggerType(StrEnum):
"""Type of sensor trigger."""
THRESHOLD = "threshold"
COUNTER = "counter"
STATE_CHANGE = "state_change"
RUNTIME = "runtime"
COMPOUND = "compound"
class TaskPriority(StrEnum):
"""Priority level of a maintenance task."""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
# The default when a task doesn't specify a priority.
DEFAULT_TASK_PRIORITY = TaskPriority.NORMAL
class HistoryEntryType(StrEnum):
"""Type of history entry."""
COMPLETED = "completed"
SKIPPED = "skipped"
MISSED = "missed"
RESET = "reset"
TRIGGERED = "triggered"
TRIGGER_REMOVED = "trigger_removed"
TRIGGER_REPLACED = "trigger_replaced"
class MaintenanceFeedback(StrEnum):
"""Feedback from user about whether maintenance was needed."""
NEEDED = "needed"
NOT_NEEDED = "not_needed"
NOT_SURE = "not_sure"
class TriggerEntityState(StrEnum):
"""State of a trigger entity from the integration's perspective."""
AVAILABLE = "available"
UNAVAILABLE = "unavailable" # Entity exists but state is unavailable/unknown
MISSING = "missing" # Entity does not exist in state machine
STARTUP = "startup" # Grace period after HA start
# --- Dispatcher Signals ---
SIGNAL_TASK_RESET = f"{DOMAIN}_task_reset_{{entry_id}}_{{task_id}}"
SIGNAL_NEW_OBJECT_ENTRY = f"{DOMAIN}_new_object_entry"
SIGNAL_OBJECT_ENTRY_REMOVED = f"{DOMAIN}_object_entry_removed"
# Fired by the DocumentStore after any metadata/blob change so the storage
# sensor (and any other listener) can refresh without polling.
SIGNAL_DOCUMENTS_UPDATED = f"{DOMAIN}_documents_updated"
# --- Trigger Completion Cooldown ---
TRIGGER_COMPLETION_COOLDOWN_SECONDS = 600 # 10 minutes
# Household double-complete window: two people tapping Complete on the same
# task within this many seconds count as ONE real-world action (journey M1).
# Short on purpose — a deliberate complete → reset → complete-again correction
# flow must never be caught by it.
MANUAL_COMPLETION_DEDUP_SECONDS = 30
# --- Input Validation Limits ---
MAX_NAME_LENGTH = 200
MAX_TEXT_LENGTH = 2000 # notes, reason, feedback, description
MAX_URL_LENGTH = 2048
MAX_ICON_LENGTH = 100 # "mdi:icon-name"
MAX_META_LENGTH = 200 # manufacturer, model, user_id, area_id, etc.
MAX_PANEL_TITLE_LENGTH = 50 # sidebar panel title override
MAX_TYPE_LENGTH = 50 # task_type, schedule_type
MAX_CHECKLIST_ITEMS = 100
MAX_CHECKLIST_ITEM_LENGTH = 500
MAX_LABELS = 25 # cross-cutting tags per task
MAX_LABEL_LENGTH = 40 # single label/tag
MAX_GROUP_TASK_REFS = 200
MAX_ID_LENGTH = 64 # entry_id, task_id, group_id (uuid hex = 32)
MAX_DATE_LENGTH = 20 # ISO 8601 date strings (e.g. 2026-04-21)
MAX_ENTITY_ID_LENGTH = 255 # HA entity_id max
MAX_ENTITY_SLUG_LENGTH = 64 # task entity_slug
MAX_INTERVAL_DAYS = 3650 # 10 years — caps date arithmetic overflow
MAX_IMPORT_PAYLOAD_BYTES = 1_048_576 # 1 MB for csv_content / json_content
MAX_SCHEDULE_TIME_LENGTH = 5 # "HH:MM"
# --- Trigger Entity Availability ---
STARTUP_GRACE_PERIOD_SECONDS = 300 # 5 minutes
MISSING_ENTITY_THRESHOLD_REFRESHES = 6 # ~30 min at 5-min intervals
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
"""Diagnostics support for the Maintenance Supporter integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .const import (
CONF_OBJECT,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceStatus,
TriggerEntityState,
)
from .helpers.schedule import read_legacy_fields
if TYPE_CHECKING:
from . import MaintenanceSupporterConfigEntry
# Fields to redact from diagnostics (downloads are admin-accessible and routinely
# pasted into public GitHub issues, so anything free-text / identifying is redacted)
TO_REDACT = {
"checklist",
"notes",
"documentation_url",
"manufacturer",
"model",
"name",
"nfc_tag_id",
"notify_service",
"responsible_user_id",
"serial_number",
}
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
is_global = entry.unique_id == GLOBAL_UNIQUE_ID
diag: dict[str, Any] = {
"entry": {
"title": entry.title,
"unique_id": entry.unique_id,
"version": entry.version,
"is_global": is_global,
},
"data": async_redact_data(entry.data, TO_REDACT),
}
if is_global:
# Global entry diagnostics
diag["options"] = async_redact_data(entry.options or {}, TO_REDACT)
diag["overview"] = _get_integration_overview(hass)
else:
# Object entry diagnostics — merge Store dynamic data for stats
runtime_data = getattr(entry, "runtime_data", None)
store = getattr(runtime_data, "store", None) if runtime_data else None
static_tasks = entry.data.get(CONF_TASKS, {})
merged_tasks = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
merged_data = dict(entry.data)
merged_data[CONF_TASKS] = merged_tasks
diag["statistics"] = _calculate_statistics(merged_data)
diag["trigger_status"] = _check_trigger_status(hass, entry.data)
diag["data_quality"] = _check_data_quality(entry.data)
# Coordinator info
runtime_data = getattr(entry, "runtime_data", None)
if runtime_data and runtime_data.coordinator:
coord = runtime_data.coordinator
diag["coordinator"] = {
"last_update_success": coord.last_update_success,
"last_update_success_time": str(getattr(coord, "last_update_success_time", None)),
"update_interval": str(coord.update_interval),
}
return diag
def _get_integration_overview(hass: HomeAssistant) -> dict[str, Any]:
"""Get an overview of all maintenance objects."""
entries = hass.config_entries.async_entries(DOMAIN)
objects = [e for e in entries if e.unique_id != GLOBAL_UNIQUE_ID]
total_tasks = 0
overdue = 0
due_soon = 0
for entry in objects:
tasks = entry.data.get(CONF_TASKS, {})
total_tasks += len(tasks)
runtime_data = getattr(entry, "runtime_data", None)
if runtime_data and runtime_data.coordinator and runtime_data.coordinator.data:
for task_data in runtime_data.coordinator.data.get("tasks", {}).values():
status = task_data.get("_status")
if status == MaintenanceStatus.OVERDUE:
overdue += 1
elif status == MaintenanceStatus.DUE_SOON:
due_soon += 1
return {
"total_objects": len(objects),
"total_tasks": total_tasks,
"overdue_tasks": overdue,
"due_soon_tasks": due_soon,
}
def _calculate_statistics(data: Mapping[str, Any]) -> dict[str, Any]:
"""Calculate statistics for a maintenance object."""
tasks = data.get(CONF_TASKS, {})
stats: dict[str, Any] = {
"total_tasks": len(tasks),
"enabled_tasks": sum(1 for t in tasks.values() if t.get("enabled", True)),
"tasks_with_triggers": sum(1 for t in tasks.values() if t.get("trigger_config")),
"tasks_by_type": {},
"tasks_by_schedule": {},
"total_history_entries": 0,
}
for task in tasks.values():
# By type
task_type = task.get("type", "unknown")
stats["tasks_by_type"][task_type] = stats["tasks_by_type"].get(task_type, 0) + 1
# By schedule (accepts flat v2.6.x or nested `schedule` storage)
schedule = read_legacy_fields(task)["schedule_type"]
stats["tasks_by_schedule"][schedule] = stats["tasks_by_schedule"].get(schedule, 0) + 1
# History
stats["total_history_entries"] += len(task.get("history", []))
return stats
def _check_trigger_status(hass: HomeAssistant, data: Mapping[str, Any]) -> list[dict[str, Any]]:
"""Check the status of all configured triggers."""
results = []
tasks = data.get(CONF_TASKS, {})
for task_id, task in tasks.items():
trigger_config = task.get("trigger_config")
if not trigger_config:
continue
entity_ids: list[str] = list(trigger_config.get("entity_ids", []))
if not entity_ids:
single = trigger_config.get("entity_id")
if single:
entity_ids = [single]
# Compound triggers: collect entity_ids from conditions
if not entity_ids and trigger_config.get("type") == "compound":
for cond in trigger_config.get("conditions", []):
for eid in cond.get("entity_ids", []):
if eid not in entity_ids:
entity_ids.append(eid)
cond_eid = cond.get("entity_id")
if cond_eid and cond_eid not in entity_ids:
entity_ids.append(cond_eid)
if not entity_ids:
continue
for eid in entity_ids:
state = hass.states.get(eid)
if state is None:
entity_health = TriggerEntityState.MISSING
elif state.state in ("unavailable", "unknown"):
entity_health = TriggerEntityState.UNAVAILABLE
else:
entity_health = TriggerEntityState.AVAILABLE
results.append(
{
"task_id": task_id,
"trigger_entity": eid,
"trigger_type": trigger_config.get("type"),
"entity_available": state is not None,
# raw entity_state intentionally omitted — it can carry PII
# (e.g. a device_tracker / person location); entity_health
# gives the available/unavailable/missing status for debugging.
"entity_health": entity_health,
}
)
return results
def _check_data_quality(data: Mapping[str, Any]) -> list[str]:
"""Check data quality and return warnings."""
warnings = []
obj = data.get(CONF_OBJECT, {})
tasks = data.get(CONF_TASKS, {})
if not obj.get("name"):
warnings.append("Object has no name")
if not tasks:
warnings.append("Object has no tasks defined")
for task_id, task in tasks.items():
if not task.get("name"):
warnings.append(f"Task {task_id} has no name")
sched = read_legacy_fields(task)
if sched["schedule_type"] == "time_based" and not sched["interval_days"]:
warnings.append(f"Task '{task.get('name', task_id)}' is time-based but has no interval")
trigger = task.get("trigger_config")
if trigger and trigger.get("type") != "compound" and not trigger.get("entity_id"):
warnings.append(f"Task '{task.get('name', task_id)}' has trigger config but no entity")
return warnings
@@ -0,0 +1,7 @@
"""Entity module for the Maintenance Supporter integration."""
from __future__ import annotations
from .entity_base import MaintenanceEntity
__all__ = ["MaintenanceEntity"]
@@ -0,0 +1,89 @@
"""Base entity for the Maintenance Supporter integration."""
from __future__ import annotations
from typing import Any
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
from ..coordinator import MaintenanceCoordinator
class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
"""Base class for Maintenance Supporter entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MaintenanceCoordinator,
task_id: str,
) -> None:
"""Initialize the base entity."""
super().__init__(coordinator)
self._task_id = task_id
self._object_data = coordinator.entry.data.get(CONF_OBJECT, {})
@property
def device_info(self) -> DeviceInfo:
"""Return device info for this maintenance object.
Three shapes (2.19, device attachment):
* ``ha_device_id`` set attach to that EXISTING device: return only
its identifiers/connections so the registry merges us onto it no
name/manufacturer, which would overwrite the owning integration's
metadata (the objdevice forward-sync skips linked objects too).
* ``parent_entry_id`` set our own device, nested under the parent
object's device via ``via_device``.
* neither our own stand-alone device (the historical shape).
"""
obj = self._object_data
hass = self.coordinator.hass
if device_id := obj.get("ha_device_id"):
device = dr.async_get(hass).async_get(device_id)
if device is not None and (device.identifiers or device.connections):
info = DeviceInfo()
if device.identifiers:
info["identifiers"] = device.identifiers
if device.connections:
info["connections"] = device.connections
return info
# Linked device vanished → fall through to an own device so the
# entities never end up device-less.
identifiers = {(DOMAIN, self.coordinator.entry.unique_id or "")}
device_info = DeviceInfo(
identifiers=identifiers,
name=obj.get("name", "Unknown"),
)
if obj.get("manufacturer"):
device_info["manufacturer"] = obj["manufacturer"]
if obj.get("model"):
device_info["model"] = obj["model"]
if obj.get("serial_number"):
device_info["serial_number"] = obj["serial_number"]
if obj.get("area_id"):
device_info["suggested_area"] = obj["area_id"]
if parent_entry_id := obj.get("parent_entry_id"):
parent = hass.config_entries.async_get_entry(parent_entry_id)
if parent is not None and parent.domain == DOMAIN and parent.unique_id and parent.unique_id != GLOBAL_UNIQUE_ID:
device_info["via_device"] = (DOMAIN, parent.unique_id)
return device_info
@property
def _task_data(self) -> dict[str, Any]:
"""Return the current task data from coordinator."""
if self.coordinator.data is None:
return {}
tasks: dict[str, Any] = self.coordinator.data.get("tasks", {})
result: dict[str, Any] = tasks.get(self._task_id, {})
return result
@@ -0,0 +1,103 @@
"""Aggregate summary coordinator for the global Maintenance Supporter entry.
Holds the cross-object status counts that back the global summary sensors.
It does not poll: it recomputes whenever any object coordinator updates, a new
object entry appears, or a sensor trigger flips mirroring the live-update
pattern used by the ``maintenance_supporter/subscribe`` WebSocket endpoint.
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from ..const import (
EVENT_TRIGGER_ACTIVATED,
EVENT_TRIGGER_DEACTIVATED,
SIGNAL_NEW_OBJECT_ENTRY,
SIGNAL_OBJECT_ENTRY_REMOVED,
)
from ..helpers.aggregate import (
compute_status_counts,
get_object_entries,
get_runtime_data,
)
_LOGGER = logging.getLogger(__name__)
class MaintenanceSummaryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Event-driven aggregator of task status counts across all objects."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the summary coordinator (no polling)."""
super().__init__(
hass,
_LOGGER,
name="Maintenance Supporter summary",
update_interval=None,
)
self._unsubs: list[CALLBACK_TYPE] = []
self._attached: dict[str, CALLBACK_TYPE] = {}
async def _async_update_data(self) -> dict[str, Any]:
"""Recompute counts from the shared aggregator (single source)."""
return compute_status_counts(self.hass)
@callback
def async_setup_listeners(self) -> None:
"""Attach to every object coordinator + new-entry/trigger signals."""
for entry in get_object_entries(self.hass):
self._attach_entry(entry.entry_id)
self._unsubs.append(async_dispatcher_connect(self.hass, SIGNAL_NEW_OBJECT_ENTRY, self._on_new_entry))
self._unsubs.append(async_dispatcher_connect(self.hass, SIGNAL_OBJECT_ENTRY_REMOVED, self._on_removed))
# Trigger activation/deactivation updates a task's _status in place but
# does NOT notify coordinator listeners, so listen for it explicitly.
self._unsubs.append(self.hass.bus.async_listen(EVENT_TRIGGER_ACTIVATED, self._on_event))
self._unsubs.append(self.hass.bus.async_listen(EVENT_TRIGGER_DEACTIVATED, self._on_event))
def _attach_entry(self, entry_id: str) -> None:
"""Register a listener on a single object coordinator (once)."""
if entry_id in self._attached:
return
rd = get_runtime_data(self.hass, entry_id)
if rd and rd.coordinator:
self._attached[entry_id] = rd.coordinator.async_add_listener(self._schedule)
@callback
def _on_new_entry(self, entry_id: str) -> None:
"""A new object entry was set up — attach and recompute."""
self._attach_entry(entry_id)
self._schedule()
@callback
def _on_removed(self, entry_id: str) -> None:
"""An object entry was deleted — detach its listener and recompute."""
unsub = self._attached.pop(entry_id, None)
if unsub is not None:
unsub()
self._schedule()
@callback
def _on_event(self, _event: Event) -> None:
self._schedule()
@callback
def _schedule(self) -> None:
"""Request a (debounced) recompute from any sync callback."""
self.hass.async_create_task(self.async_request_refresh())
@callback
def async_teardown_listeners(self) -> None:
"""Detach all listeners (called on global entry unload)."""
for unsub in self._unsubs:
unsub()
for unsub in self._attached.values():
unsub()
self._unsubs.clear()
self._attached.clear()
@@ -0,0 +1,189 @@
"""Trigger system for sensor-based maintenance tasks."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import TriggerType
from .base_trigger import BaseTrigger
from .compound import CompoundTrigger
from .counter import CounterTrigger
from .runtime import RuntimeTrigger
from .state_change import StateChangeTrigger
from .threshold import ThresholdTrigger
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ...sensor import MaintenanceSensor
def normalize_entity_ids(trigger_config: dict[str, Any]) -> list[str]:
"""Return all entity IDs referenced by a trigger config.
For non-compound triggers, reads the new ``entity_ids`` list or legacy
``entity_id`` string from the root.
For compound triggers (``type == "compound"``), recursively collects
entity IDs from all conditions. A condition may have ``entity_id`` /
``entity_ids`` at top level OR nested inside a ``trigger_config`` sub-dict.
Order is preserved, duplicates are removed.
Always returns a list (possibly empty). Recursion depth is bounded since
nested compound triggers are rejected by validation.
"""
if trigger_config.get("type") == "compound":
seen: set[str] = set()
result: list[str] = []
for cond in trigger_config.get("conditions", []):
cond_ids = normalize_entity_ids(cond)
if not cond_ids:
cond_ids = normalize_entity_ids(cond.get("trigger_config", {}))
for cond_eid in cond_ids:
if cond_eid not in seen:
seen.add(cond_eid)
result.append(cond_eid)
return result
ids = trigger_config.get("entity_ids")
if isinstance(ids, list) and ids:
return list(ids)
flat_eid = trigger_config.get("entity_id")
if flat_eid:
return [flat_eid]
return []
def _migrate_flat_to_per_entity(config: dict[str, Any], first_entity_id: str) -> dict[str, dict[str, Any]]:
"""Create ``_trigger_state`` from legacy flat keys on first load.
Returns a dict keyed by entity_id with per-entity state. Only creates
an entry for *first_entity_id* since legacy configs only had one entity.
"""
trigger_type = config.get("type", TriggerType.THRESHOLD)
state: dict[str, Any] = {}
if trigger_type == TriggerType.COUNTER:
bv = config.get("trigger_baseline_value")
if bv is not None:
state["baseline_value"] = bv
elif trigger_type == TriggerType.RUNTIME:
acc = config.get("trigger_accumulated_seconds")
if acc is not None:
state["accumulated_seconds"] = acc
on_since = config.get("trigger_on_since")
if on_since is not None:
state["on_since"] = on_since
elif trigger_type == TriggerType.STATE_CHANGE:
cc = config.get("trigger_change_count")
if cc is not None:
state["change_count"] = cc
if state:
return {first_entity_id: state}
return {}
def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any]) -> None:
"""Inject per-entity persisted state into a trigger config for construction.
Overwrites the flat keys that each trigger reads in ``__init__`` so that
the trigger instance picks up the correct per-entity values.
"""
trigger_type = config.get("type", TriggerType.THRESHOLD)
if trigger_type == TriggerType.COUNTER:
if "baseline_value" in entity_state:
config["trigger_baseline_value"] = entity_state["baseline_value"]
elif trigger_type == TriggerType.RUNTIME:
if "accumulated_seconds" in entity_state:
config["trigger_accumulated_seconds"] = entity_state["accumulated_seconds"]
if "on_since" in entity_state:
config["trigger_on_since"] = entity_state["on_since"]
elif trigger_type == TriggerType.STATE_CHANGE:
if "change_count" in entity_state:
config["trigger_change_count"] = entity_state["change_count"]
elif trigger_type == TriggerType.THRESHOLD:
tes = entity_state.get("threshold_exceeded_since")
if tes:
config["trigger_threshold_exceeded_since"] = tes
def create_trigger(
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> BaseTrigger:
"""Create a trigger instance based on trigger type."""
trigger_type = trigger_config.get("type", TriggerType.THRESHOLD)
if trigger_type == TriggerType.THRESHOLD:
return ThresholdTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.COUNTER:
return CounterTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.STATE_CHANGE:
return StateChangeTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.RUNTIME:
return RuntimeTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.COMPOUND:
return CompoundTrigger(hass, entity, trigger_config)
raise ValueError(f"Unknown trigger type: {trigger_type}")
def create_triggers(
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> list[BaseTrigger]:
"""Create trigger instances for all entity_ids in the config.
Creates one trigger per entity_id for all trigger types that support
multi-entity. For counter/runtime/state_change, per-entity persisted
state is injected from ``_trigger_state`` before construction.
"""
# Compound triggers manage their own sub-triggers; always return one.
trigger_type = trigger_config.get("type", TriggerType.THRESHOLD)
if trigger_type == TriggerType.COMPOUND:
return [CompoundTrigger(hass, entity, trigger_config)]
entity_ids = normalize_entity_ids(trigger_config)
if not entity_ids:
raise ValueError("No entity_id(s) in trigger_config")
if len(entity_ids) == 1:
single_config = dict(trigger_config)
single_config["entity_id"] = entity_ids[0]
# Inject per-entity persisted state for single entity too
entity_state = trigger_config.get("_trigger_state", {}).get(entity_ids[0], {})
if entity_state:
_inject_per_entity_state(single_config, entity_state)
return [create_trigger(hass, entity, single_config)]
# Ensure _trigger_state exists (migrate from flat keys if needed)
if "_trigger_state" not in trigger_config:
trigger_config["_trigger_state"] = _migrate_flat_to_per_entity(trigger_config, entity_ids[0])
# Multi-entity: one trigger per entity_id
triggers: list[BaseTrigger] = []
for eid in entity_ids:
per_entity_config = dict(trigger_config)
per_entity_config["entity_id"] = eid
# Inject per-entity persisted state
entity_state = trigger_config.get("_trigger_state", {}).get(eid, {})
_inject_per_entity_state(per_entity_config, entity_state)
triggers.append(create_trigger(hass, entity, per_entity_config))
return triggers
__all__ = [
"BaseTrigger",
"CompoundTrigger",
"CounterTrigger",
"RuntimeTrigger",
"StateChangeTrigger",
"ThresholdTrigger",
"create_trigger",
"create_triggers",
"normalize_entity_ids",
]
@@ -0,0 +1,309 @@
"""Base trigger class for sensor-based maintenance triggers."""
from __future__ import annotations
import logging
import math
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
EventStateChangedData,
async_call_later,
async_track_state_change_event,
)
if TYPE_CHECKING:
from ...coordinator import MaintenanceCoordinator
from ...sensor import MaintenanceSensor
from ...const import (
EVENT_TRIGGER_ACTIVATED,
EVENT_TRIGGER_DEACTIVATED,
)
_LOGGER = logging.getLogger(__name__)
class BaseTrigger(ABC):
"""Base class for all maintenance triggers."""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize the trigger."""
self.hass = hass
self.entity = entity
self.config = trigger_config
self.entity_id = trigger_config.get("entity_id", "")
self.attribute = trigger_config.get("attribute")
self._triggered = False
self._current_value: float | None = None
self._unsub_listener: CALLBACK_TYPE | None = None
self._unsub_retry: CALLBACK_TYPE | None = None
self._logged_unavailable = False # Log-once pattern for unavailable
@property
def _coordinator(self) -> MaintenanceCoordinator:
"""Get the coordinator from the entity."""
return self.entity.coordinator
@property
def _task_id(self) -> str:
"""Get the task ID from the entity."""
return self.entity._task_id
async def async_setup(self) -> None:
"""Set up the trigger: validate entity and register listener.
IMPORTANT: The listener is ALWAYS registered, even when the entity does
not exist yet. HA fires a state_change event when an entity first
appears (old_state=None), so the trigger will self-heal automatically.
"""
state = self.hass.states.get(self.entity_id)
if state is None:
_LOGGER.info(
"Trigger entity %s not yet available — listener registered, waiting for entity to appear",
self.entity_id,
)
# Register listener anyway so we catch the entity appearing
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event)
return
# Register state change listener
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event)
# If state is unknown/unavailable, schedule a retry
if state.state in ("unavailable", "unknown"):
_LOGGER.info(
"Trigger entity %s is '%s' — will retry evaluation in 30s",
self.entity_id,
state.state,
)
self._schedule_retry()
return
# Validate that we can get a value
value = self._get_numeric_value(state)
if value is not None:
self._current_value = value
# Initial evaluation
if value is not None:
self._evaluate_and_update(value)
_LOGGER.debug(
"Trigger setup complete: %s monitoring %s (attribute=%s, value=%s)",
type(self).__name__,
self.entity_id,
self.attribute,
self._current_value,
)
def _schedule_retry(self) -> None:
"""Schedule a retry of the initial evaluation after 30 seconds."""
self._cancel_retry()
@callback
def _retry_initial_evaluation(_now: datetime) -> None:
"""Re-check entity state after a delay."""
self._unsub_retry = None
state = self.hass.states.get(self.entity_id)
if state is None or state.state in ("unavailable", "unknown"):
_LOGGER.debug(
"Trigger entity %s still %s after retry",
self.entity_id,
state.state if state else "missing",
)
return
value = self._get_numeric_value(state)
if value is not None:
self._current_value = value
self._evaluate_and_update(value)
_LOGGER.info(
"Trigger entity %s recovered after retry (value=%s)",
self.entity_id,
value,
)
self._unsub_retry = async_call_later(self.hass, 30, _retry_initial_evaluation)
def _cancel_retry(self) -> None:
"""Cancel pending retry timer."""
if self._unsub_retry is not None:
self._unsub_retry()
self._unsub_retry = None
async def async_teardown(self) -> None:
"""Remove the trigger listener."""
self._cancel_retry()
if self._unsub_listener is not None:
self._unsub_listener()
self._unsub_listener = None
_LOGGER.debug("Trigger teardown: %s", self.entity_id)
@callback
def _handle_state_change_event(self, event: Event[EventStateChangedData]) -> None:
"""Handle state change event from the monitored entity."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if new_state is None:
# Entity removed from state machine
return
# Entity appeared for the first time (old_state=None)
if old_state is None:
_LOGGER.info(
"Trigger entity %s appeared in state machine (state=%s)",
self.entity_id,
new_state.state,
)
self._logged_unavailable = False
# Handle unavailable/unknown with log-once pattern.
# A transient unavailable/unknown carries no measurement, so we treat
# it as "no new data" and do NOT deactivate an active trigger here.
# Deactivating on a blip dropped real (alarm) triggers and, combined
# with the threshold for-minutes debounce latch, left tasks stuck at
# OK once the value returned below threshold. A genuinely missing
# trigger entity is surfaced via the missing_trigger_entity repair
# flow instead. (Numeric-only triggers: any non-numeric value below
# is likewise ignored via the _get_numeric_value None check.)
if new_state.state in ("unavailable", "unknown"):
if not self._logged_unavailable:
_LOGGER.warning(
"Trigger entity %s became %s — keeping last trigger state",
self.entity_id,
new_state.state,
)
self._logged_unavailable = True
return
# Entity is back to a valid state
if self._logged_unavailable:
_LOGGER.info(
"Trigger entity %s is available again (state=%s)",
self.entity_id,
new_state.state,
)
self._logged_unavailable = False
value = self._get_numeric_value(new_state)
if value is not None:
self._current_value = value
self._evaluate_and_update(value)
def _evaluate_and_update(self, value: float) -> None:
"""Evaluate trigger condition and update state if changed."""
was_triggered = self._triggered
is_triggered = self.evaluate(value)
self._triggered = is_triggered
if is_triggered and not was_triggered:
self._on_trigger_activated(value)
elif not is_triggered and was_triggered:
self._on_trigger_deactivated(value)
@abstractmethod
def evaluate(self, value: float) -> bool:
"""Evaluate whether the trigger condition is met.
Must be implemented by subclasses.
Returns True if trigger should be active.
"""
def _on_trigger_activated(self, value: float) -> None:
"""Handle trigger activation."""
_LOGGER.info(
"Maintenance trigger activated: %s = %s (entity: %s)",
self.entity.entity_id,
value,
self.entity_id,
)
# Update entity state
self.entity.async_update_trigger_state(
is_triggered=True,
current_value=value,
trigger_entity_id=self.entity_id,
)
# Add history entry for the trigger activation
self.hass.async_create_task(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=value))
# Fire event
self.hass.bus.async_fire(
EVENT_TRIGGER_ACTIVATED,
{
"entity_id": self.entity.entity_id,
"trigger_entity": self.entity_id,
"trigger_attribute": self.attribute,
"trigger_value": value,
"trigger_type": self.config.get("type"),
},
)
def _on_trigger_deactivated(self, value: float) -> None:
"""Handle trigger deactivation."""
_LOGGER.info(
"Maintenance trigger deactivated: %s = %s (entity: %s)",
self.entity.entity_id,
value,
self.entity_id,
)
# Update entity state
self.entity.async_update_trigger_state(
is_triggered=False,
current_value=value,
trigger_entity_id=self.entity_id,
)
# Fire event
self.hass.bus.async_fire(
EVENT_TRIGGER_DEACTIVATED,
{
"entity_id": self.entity.entity_id,
"trigger_entity": self.entity_id,
"trigger_attribute": self.attribute,
"trigger_value": value,
"trigger_type": self.config.get("type"),
},
)
# Opt-in (#53): the sensor recovering IS the maintenance being done
# (salt refilled, filter swapped) — record the completion so
# last_performed and the time-between-services statistics stay real.
# This hook only fires on the evaluate path: a manual complete/skip
# resets the trigger via reset(), which never lands here, so the
# manual flow cannot double-record.
if self.config.get("auto_complete_on_recovery"):
self.hass.async_create_task(self._coordinator.async_auto_complete_on_recovery(self._task_id, value))
def _get_numeric_value(self, state: State) -> float | None:
"""Extract numeric value from state or attribute."""
try:
if self.attribute:
raw = state.attributes.get(self.attribute)
else:
raw = state.state
if raw is None:
return None
val = float(raw)
if not math.isfinite(val):
return None
return val
except (ValueError, TypeError):
return None
def reset(self) -> None:
"""Reset the trigger (called after maintenance completion)."""
self._triggered = False
@@ -0,0 +1,282 @@
"""Compound trigger — combines multiple trigger conditions with AND/OR logic."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant, callback
if TYPE_CHECKING:
from ...coordinator import MaintenanceCoordinator
from ...sensor import MaintenanceSensor
from ...const import (
CONF_COMPOUND_CONDITIONS,
CONF_COMPOUND_LOGIC,
EVENT_TRIGGER_ACTIVATED,
EVENT_TRIGGER_DEACTIVATED,
)
from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
class CompoundSubEntity:
"""Proxy entity for a single compound condition.
Each condition creates its own sub-triggers that call back into this
proxy. The proxy aggregates per-entity states within the condition
(using the condition's ``entity_logic``), then notifies the parent
``CompoundTrigger`` of the condition-level result.
"""
def __init__(
self,
parent: CompoundTrigger,
condition_idx: int,
condition_config: dict[str, Any],
) -> None:
"""Initialize the sub-entity proxy."""
self._parent = parent
self._condition_idx = condition_idx
self._condition_config = condition_config
# Pre-seed a MULTI-entity condition with every entity at False, so
# `entity_logic == "all"` quantifies over EVERY entity, not just the ones
# that have already transitioned (an entity that never toggles must keep
# the AND from firing). Single-entity conditions stay empty and use the
# is_triggered fallback in async_update_trigger_state. (H1)
_eids = condition_config.get("entity_ids") or []
self._per_entity_states: dict[str, bool] = {eid: False for eid in _eids} if len(_eids) > 1 else {}
self._per_entity_values: dict[str, float | None] = {}
self._entity_logic = condition_config.get("entity_logic", "any")
# Mirror attributes the real entity exposes for trigger access
self.entity_id = parent.entity.entity_id
self._task_id = parent.entity._task_id
self.coordinator = parent.entity.coordinator
@callback
def async_update_trigger_state(
self,
is_triggered: bool,
current_value: float | None = None,
trigger_entity_id: str | None = None,
) -> None:
"""Receive trigger state from a sub-trigger."""
if trigger_entity_id is not None:
self._per_entity_states[trigger_entity_id] = is_triggered
if current_value is not None:
self._per_entity_values[trigger_entity_id] = current_value
# Aggregate within this condition
if not self._per_entity_states:
aggregated = is_triggered
elif self._entity_logic == "all":
aggregated = bool(self._per_entity_states) and all(self._per_entity_states.values())
else: # "any"
aggregated = any(self._per_entity_states.values())
self._parent._on_condition_changed(self._condition_idx, aggregated)
@callback
def async_write_ha_state(self) -> None:
"""No-op — the compound trigger manages state through the real entity."""
class _CompoundCoordinatorProxy:
"""Proxy coordinator that routes persistence to the correct condition index.
Wraps the real coordinator so that ``async_persist_trigger_runtime``
stores data under ``_trigger_state.conditions[idx][entity_id]``.
"""
def __init__(self, real_coordinator: MaintenanceCoordinator, condition_idx: int) -> None:
"""Initialize the proxy."""
self._real = real_coordinator
self._condition_idx = condition_idx
def __getattr__(self, name: str) -> Any:
"""Delegate all other attributes to the real coordinator."""
return getattr(self._real, name)
async def async_persist_trigger_runtime(
self,
task_id: str,
runtime_data: dict[str, Any],
entity_id: str | None = None,
*,
immediate: bool = False,
) -> None:
"""Persist under trigger_runtime as a per-condition compound key.
The real coordinator always has a Store; merge_task_data reshapes these
``_compound_<idx>[_<entity_id>]`` keys back into
``_trigger_state["conditions"][idx]`` on read.
"""
store = self._real._store
compound_key = f"_compound_{self._condition_idx}"
if entity_id is not None:
compound_key = f"_compound_{self._condition_idx}_{entity_id}"
store.set_trigger_runtime(task_id, compound_key, runtime_data)
if immediate:
await store.async_save()
else:
store.async_delay_save()
class CompoundTrigger(BaseTrigger):
"""Compound trigger that combines multiple conditions with AND/OR logic.
Two-level aggregation:
1. Within each condition: multi-entity ``entity_logic`` (any/all)
2. Across conditions: ``compound_logic`` (AND/OR)
"""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize the compound trigger."""
# BaseTrigger expects entity_id; compound has no single monitored entity
config_with_id = dict(trigger_config)
config_with_id.setdefault("entity_id", "")
super().__init__(hass, entity, config_with_id)
# `or "AND"` (not just the .get default) so a present-but-null value in
# hand-edited config doesn't crash on .upper().
self._compound_logic: str = (trigger_config.get(CONF_COMPOUND_LOGIC) or "AND").upper()
self._conditions: list[dict[str, Any]] = trigger_config.get(CONF_COMPOUND_CONDITIONS) or []
self._condition_states: list[bool] = [False] * len(self._conditions)
self._sub_triggers: list[list[BaseTrigger]] = []
self._sub_entities: list[CompoundSubEntity] = []
@property
def condition_states(self) -> list[bool]:
"""Return the current state of each condition."""
return list(self._condition_states)
async def async_setup(self) -> None:
"""Set up all sub-triggers for each condition."""
from . import create_triggers
trigger_state = self.config.get("_trigger_state", {})
conditions_state = trigger_state.get("conditions", [])
for idx, condition in enumerate(self._conditions):
sub_entity = CompoundSubEntity(self, idx, condition)
self._sub_entities.append(sub_entity)
# Build per-condition config with its persisted state
cond_config = dict(condition)
if idx < len(conditions_state):
cond_state = conditions_state[idx]
if cond_state:
cond_config["_trigger_state"] = cond_state
# Wrap the real coordinator with a proxy for persistence
proxy_coordinator = _CompoundCoordinatorProxy(self._coordinator, idx)
sub_entity.coordinator = proxy_coordinator # type: ignore[assignment]
sub_triggers = create_triggers(self.hass, sub_entity, cond_config) # type: ignore[arg-type]
self._sub_triggers.append(sub_triggers)
for trigger in sub_triggers:
await trigger.async_setup()
_LOGGER.debug(
"Compound trigger setup: %d conditions with %s logic for %s",
len(self._conditions),
self._compound_logic,
self.entity.entity_id,
)
async def async_teardown(self) -> None:
"""Tear down all sub-triggers."""
for trigger_list in self._sub_triggers:
for trigger in trigger_list:
await trigger.async_teardown()
self._sub_triggers = []
self._sub_entities = []
self._condition_states = [False] * len(self._conditions)
await super().async_teardown()
def evaluate(self, value: float) -> bool:
"""Evaluate compound condition (aggregation of condition states)."""
if self._compound_logic == "AND":
return bool(self._condition_states) and all(self._condition_states)
return any(self._condition_states) # OR
def reset(self) -> None:
"""Reset all sub-triggers."""
super().reset()
self._condition_states = [False] * len(self._conditions)
for trigger_list in self._sub_triggers:
for trigger in trigger_list:
trigger.reset()
@callback
def _on_condition_changed(self, condition_idx: int, is_triggered: bool) -> None:
"""Handle a condition state change and re-aggregate."""
self._condition_states[condition_idx] = is_triggered
was_triggered = self._triggered
if self._compound_logic == "AND":
now_triggered = bool(self._condition_states) and all(self._condition_states)
else: # OR
now_triggered = any(self._condition_states)
self._triggered = now_triggered
if now_triggered and not was_triggered:
self._on_trigger_activated(0.0)
elif not now_triggered and was_triggered:
self._on_trigger_deactivated(0.0)
def _on_trigger_activated(self, value: float) -> None:
"""Handle compound trigger activation."""
_LOGGER.info(
"Compound trigger activated: %s (conditions: %s, logic: %s)",
self.entity.entity_id,
self._condition_states,
self._compound_logic,
)
self.entity.async_update_trigger_state(
is_triggered=True,
current_value=None,
trigger_entity_id=None,
)
self.hass.async_create_task(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=None))
self.hass.bus.async_fire(
EVENT_TRIGGER_ACTIVATED,
{
"entity_id": self.entity.entity_id,
"trigger_type": "compound",
"compound_logic": self._compound_logic,
"condition_states": list(self._condition_states),
},
)
def _on_trigger_deactivated(self, value: float) -> None:
"""Handle compound trigger deactivation."""
_LOGGER.info(
"Compound trigger deactivated: %s (conditions: %s, logic: %s)",
self.entity.entity_id,
self._condition_states,
self._compound_logic,
)
self.entity.async_update_trigger_state(
is_triggered=False,
current_value=None,
trigger_entity_id=None,
)
self.hass.bus.async_fire(
EVENT_TRIGGER_DEACTIVATED,
{
"entity_id": self.entity.entity_id,
"trigger_type": "compound",
"compound_logic": self._compound_logic,
"condition_states": list(self._condition_states),
},
)
@@ -0,0 +1,161 @@
"""Counter trigger for maintenance tasks."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
class CounterTrigger(BaseTrigger):
"""Trigger that activates when a counter reaches a target value.
Supports:
- Absolute mode: triggers when value >= target
- Delta mode: triggers when (value - baseline) >= target
"""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize counter trigger."""
super().__init__(hass, entity, trigger_config)
self._target_value: float = trigger_config.get("trigger_target_value", 0)
self._delta_mode: bool = trigger_config.get("trigger_delta_mode", False)
self._baseline_value: float | None = trigger_config.get("trigger_baseline_value")
# Set when reset_baseline is called while the source is unavailable, so
# the re-baseline is applied against the next real value (M4).
self._reset_pending: bool = False
async def async_setup(self) -> None:
"""Set up counter trigger with baseline initialization."""
# Restore persisted baseline BEFORE super().async_setup() which calls
# evaluate(). Without this, evaluate() sets _baseline_value to the
# current sensor value, discarding the saved baseline from the Store.
if self._delta_mode and self._baseline_value is None:
saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value")
if saved is not None:
self._baseline_value = saved
_LOGGER.debug(
"Counter baseline restored from store: %s = %s",
self.entity_id,
self._baseline_value,
)
await super().async_setup()
# Set initial baseline if in delta mode and no baseline exists
if self._delta_mode and self._baseline_value is None and self._current_value is not None:
self._baseline_value = self._current_value
await self._persist_baseline()
_LOGGER.debug(
"Counter baseline initialized: %s = %s",
self.entity_id,
self._baseline_value,
)
def _evaluate_and_update(self, value: float) -> None:
"""Initialize/re-baseline before evaluation, then persist."""
if self._delta_mode and self._reset_pending:
# Apply the deferred post-completion re-baseline now that a real
# value is available (the source was unavailable at completion). (M4)
self._baseline_value = value
self._reset_pending = False
self.hass.async_create_task(
self._persist_baseline(),
eager_start=False,
)
elif self._delta_mode and self._baseline_value is None:
self._baseline_value = value
self.hass.async_create_task(
self._persist_baseline(),
eager_start=False,
)
_LOGGER.debug(
"Counter baseline initialized on state change: %s = %s",
self.entity_id,
self._baseline_value,
)
super()._evaluate_and_update(value)
def evaluate(self, value: float) -> bool:
"""Evaluate counter condition."""
if self._delta_mode:
if self._baseline_value is None:
# Fallback — should be caught by _evaluate_and_update
self._baseline_value = value
return False
if value < self._baseline_value:
# Source counter reset / rolled over (device reboot, meter
# rollover) — re-baseline to the new lower value so we don't
# miss the next interval waiting for it to climb back past the
# old baseline. (M3)
self._baseline_value = value
self.hass.async_create_task(
self._persist_baseline(),
eager_start=False,
)
return False
delta = value - self._baseline_value
return delta >= self._target_value
# Absolute mode
return value >= self._target_value
@property
def current_delta(self) -> float | None:
"""Return the current delta from baseline."""
if not self._delta_mode or self._baseline_value is None:
return None
if self._current_value is None:
return None
return self._current_value - self._baseline_value
def reset_baseline(self) -> None:
"""Reset the baseline to current value (after maintenance)."""
if self._current_value is not None:
self._baseline_value = self._current_value
self._reset_pending = False
self.hass.async_create_task(self._persist_baseline())
_LOGGER.debug(
"Counter baseline reset: %s = %s",
self.entity_id,
self._baseline_value,
)
elif self._delta_mode:
# Source unavailable at completion — defer the re-baseline to the
# next real value so a stale baseline can't immediately re-trigger
# the just-completed task when the entity returns. (M4; delta-only,
# absolute mode has no baseline to defer.)
self._reset_pending = True
_LOGGER.debug(
"Counter baseline reset deferred (source unavailable): %s",
self.entity_id,
)
async def _persist_baseline(self) -> None:
"""Persist baseline value to the Store for survival across restarts."""
if self._baseline_value is not None:
await self._coordinator.async_persist_trigger_runtime(
self._task_id,
{"baseline_value": self._baseline_value},
entity_id=self.entity_id,
immediate=True,
)
def reset(self) -> None:
"""Reset trigger and baseline."""
super().reset()
self.reset_baseline()
@@ -0,0 +1,324 @@
"""Runtime trigger for maintenance tasks.
Tracks accumulated 'on' time of a binary entity (input_boolean, switch,
binary_sensor, etc.) and triggers when the total runtime reaches a
configured threshold in hours.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.event import (
EventStateChangedData,
async_track_state_change_event,
async_track_time_interval,
)
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from homeassistant.util import dt as dt_util
from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
_DEFAULT_ON_STATES = frozenset({"on", "1", "true"})
# Persist accumulated runtime every 5 minutes to minimise data loss on crash
_PERSIST_INTERVAL = timedelta(minutes=5)
class RuntimeTrigger(BaseTrigger):
"""Trigger that activates when accumulated runtime reaches target hours.
Monitors a binary entity (on/off) and accumulates time spent in the 'on'
state. Triggers when accumulated hours >= configured threshold.
"""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize runtime trigger."""
super().__init__(hass, entity, trigger_config)
self._target_hours: float = trigger_config.get("trigger_runtime_hours", 100.0)
self._accumulated_seconds: float = trigger_config.get("trigger_accumulated_seconds", 0.0)
# Restore on_since timestamp for restart recovery
on_since_str = trigger_config.get("trigger_on_since")
self._on_since: str | None = None # ISO string stored for persistence
self._on_since_dt: datetime | None = None # parsed datetime for calculation
if on_since_str:
parsed = dt_util.parse_datetime(on_since_str)
if parsed:
self._on_since = on_since_str
self._on_since_dt = parsed
# Custom ON states (default: on, 1, true)
custom_on = trigger_config.get("trigger_on_states")
if custom_on and isinstance(custom_on, list):
self._on_states: frozenset[str] = frozenset(s.lower().strip() for s in custom_on if isinstance(s, str) and s.strip())
else:
self._on_states = _DEFAULT_ON_STATES
self._unsub_periodic: CALLBACK_TYPE | None = None
async def async_setup(self) -> None:
"""Set up runtime trigger with state restoration."""
state = self.hass.states.get(self.entity_id)
if state is None:
_LOGGER.info(
"Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear",
self.entity_id,
)
self._unsub_listener = async_track_state_change_event(
self.hass,
[self.entity_id],
self._handle_runtime_state_change,
)
self._start_periodic_timer()
return
# Entity exists — check if currently ON
if self._is_on(state.state):
if self._on_since_dt is None:
# No restored timestamp — start tracking from now
now = dt_util.utcnow()
self._on_since_dt = now
self._on_since = now.isoformat()
await self._persist_runtime()
_LOGGER.debug(
"Runtime trigger: entity %s is ON, started tracking from now",
self.entity_id,
)
else:
# Entity is OFF — clear any stale on_since
if self._on_since_dt is not None:
# Was ON before restart but now OFF — accumulate the gap
self._accumulate_elapsed()
self._on_since_dt = None
self._on_since = None
await self._persist_runtime()
# Register state change listener
self._unsub_listener = async_track_state_change_event(
self.hass,
[self.entity_id],
self._handle_runtime_state_change,
)
# Start periodic persistence timer
self._start_periodic_timer()
# Initial evaluation
current_hours = self._get_current_runtime_hours()
self._current_value = current_hours
self._evaluate_and_update(current_hours)
_LOGGER.debug(
"Runtime trigger setup: %s (target=%.1fh, accumulated=%.2fh, on_since=%s)",
self.entity_id,
self._target_hours,
self._accumulated_seconds / 3600.0,
self._on_since,
)
def _start_periodic_timer(self) -> None:
"""Start the periodic persistence timer."""
self._unsub_periodic = async_track_time_interval(
self.hass,
self._periodic_callback,
_PERSIST_INTERVAL,
)
async def async_teardown(self) -> None:
"""Remove listeners and periodic timer."""
if self._unsub_periodic is not None:
self._unsub_periodic()
self._unsub_periodic = None
await super().async_teardown()
@callback
def _handle_runtime_state_change(self, event: Event[EventStateChangedData]) -> None:
"""Handle state changes for runtime accumulation."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if new_state is None:
return
new_val = new_state.state
# Entity appeared for the first time
if old_state is None:
_LOGGER.info(
"Runtime trigger entity %s appeared (state=%s)",
self.entity_id,
new_val,
)
self._logged_unavailable = False
if self._is_on(new_val) and self._on_since_dt is None:
now = dt_util.utcnow()
self._on_since_dt = now
self._on_since = now.isoformat()
self.hass.async_create_task(self._persist_runtime())
self._update_evaluation()
return
old_val = old_state.state
# Handle unavailable/unknown — pause accumulation
if new_val in ("unavailable", "unknown"):
if self._on_since_dt is not None:
self._accumulate_elapsed()
self._on_since_dt = None
self._on_since = None
self.hass.async_create_task(self._persist_runtime())
if not self._logged_unavailable:
_LOGGER.warning(
"Runtime trigger entity %s became %s (runtime paused)",
self.entity_id,
new_val,
)
self._logged_unavailable = True
return
# Entity back to valid state
if self._logged_unavailable:
_LOGGER.info(
"Runtime trigger entity %s available again (state=%s)",
self.entity_id,
new_val,
)
self._logged_unavailable = False
was_on = self._is_on(old_val)
now_on = self._is_on(new_val)
if was_on and not now_on:
# Turned OFF — accumulate elapsed time
self._accumulate_elapsed()
self._on_since_dt = None
self._on_since = None
self.hass.async_create_task(self._persist_runtime())
_LOGGER.debug(
"Runtime trigger: %s turned OFF (accumulated=%.2fh)",
self.entity_id,
self._accumulated_seconds / 3600.0,
)
elif not was_on and now_on:
# Turned ON — start tracking
now = dt_util.utcnow()
self._on_since_dt = now
self._on_since = now.isoformat()
self.hass.async_create_task(self._persist_runtime())
_LOGGER.debug(
"Runtime trigger: %s turned ON (tracking started)",
self.entity_id,
)
self._update_evaluation()
def _update_evaluation(self) -> None:
"""Evaluate trigger condition with current runtime."""
current_hours = self._get_current_runtime_hours()
self._current_value = current_hours
self._evaluate_and_update(current_hours)
def _is_on(self, state_value: str) -> bool:
"""Check if state represents 'on'."""
return state_value.lower() in self._on_states
def _accumulate_elapsed(self, now: datetime | None = None) -> None:
"""Add elapsed time since _on_since to accumulated total."""
if self._on_since_dt is None:
return
now = now or dt_util.utcnow()
elapsed = (now - self._on_since_dt).total_seconds()
if elapsed > 0:
self._accumulated_seconds += elapsed
def _get_current_runtime_hours(self) -> float:
"""Get current runtime in hours (accumulated + ongoing if ON)."""
total_seconds = self._accumulated_seconds
if self._on_since_dt is not None:
elapsed = (dt_util.utcnow() - self._on_since_dt).total_seconds()
if elapsed > 0:
total_seconds += elapsed
return total_seconds / 3600.0
def evaluate(self, value: float) -> bool:
"""Evaluate whether runtime threshold is met."""
return value >= self._target_hours
@callback
def _periodic_callback(self, _now: datetime) -> None:
"""Periodic callback to persist runtime every 5 minutes."""
if self._on_since_dt is None:
return
# Accumulate elapsed, reset window, persist
now = dt_util.utcnow()
self._accumulate_elapsed(now)
self._on_since_dt = now
self._on_since = now.isoformat()
self.hass.async_create_task(self._persist_runtime())
# Re-evaluate (runtime may have crossed threshold)
self._update_evaluation()
_LOGGER.debug(
"Runtime trigger periodic persist: %s (accumulated=%.2fh)",
self.entity_id,
self._accumulated_seconds / 3600.0,
)
async def _persist_runtime(self) -> None:
"""Persist accumulated runtime and on_since to the Store."""
data: dict[str, Any] = {
"accumulated_seconds": self._accumulated_seconds,
"on_since": self._on_since,
}
await self._coordinator.async_persist_trigger_runtime(
self._task_id,
data,
entity_id=self.entity_id,
)
def reset(self) -> None:
"""Reset accumulated runtime (called after maintenance completion)."""
super().reset()
self._accumulated_seconds = 0.0
# If entity is currently ON, keep tracking from now (fresh start)
if self._on_since_dt is not None:
now = dt_util.utcnow()
self._on_since_dt = now
self._on_since = now.isoformat()
self.hass.async_create_task(self._persist_runtime())
_LOGGER.debug(
"Runtime trigger reset: %s (accumulated hours cleared)",
self.entity_id,
)
# --- Properties for sensor attributes ---
@property
def accumulated_hours(self) -> float:
"""Return persisted accumulated hours (not including ongoing session)."""
return self._accumulated_seconds / 3600.0
@property
def current_runtime_hours(self) -> float:
"""Return total runtime including ongoing session."""
return self._get_current_runtime_hours()
@property
def remaining_hours(self) -> float:
"""Return hours remaining until trigger fires."""
return max(0.0, self._target_hours - self._get_current_runtime_hours())
@@ -0,0 +1,200 @@
"""State change trigger for maintenance tasks."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.event import (
EventStateChangedData,
async_track_state_change_event,
)
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
class StateChangeTrigger(BaseTrigger):
"""Trigger that activates after counting state transitions.
Counts transitions matching from_state -> to_state pattern.
Triggers when count reaches target_changes.
"""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize state change trigger."""
super().__init__(hass, entity, trigger_config)
self._from_state: str | None = trigger_config.get("trigger_from_state")
self._to_state: str | None = trigger_config.get("trigger_to_state")
self._target_changes: int = trigger_config.get("trigger_target_changes", 1)
# Restore persisted change count from config, default to 0
self._change_count: int = trigger_config.get("trigger_change_count", 0)
self._current_value = float(self._change_count)
self._last_state: str | None = None
async def async_setup(self) -> None:
"""Set up state change trigger.
IMPORTANT: The listener is ALWAYS registered, even when the entity does
not exist yet. HA fires a state_change event when an entity first
appears (old_state=None), so the trigger will self-heal automatically.
"""
state = self.hass.states.get(self.entity_id)
if state is None:
_LOGGER.info(
"Trigger entity %s not yet available — listener registered, waiting for entity to appear",
self.entity_id,
)
# Register listener anyway so we catch the entity appearing
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
return
self._last_state = state.state
# Restore triggered state from persisted change count
if self._change_count >= self._target_changes:
self._triggered = True
self.entity.async_update_trigger_state(
is_triggered=True,
current_value=float(self._change_count),
trigger_entity_id=self.entity_id,
)
# Register state change listener (override base: we handle events differently)
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
_LOGGER.debug(
"State change trigger setup: %s (target=%d, count=%d, from=%s, to=%s)",
self.entity_id,
self._target_changes,
self._change_count,
self._from_state,
self._to_state,
)
@callback
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
"""Handle state transition and count matching changes."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if new_state is None:
# Entity removed from state machine
return
new_val = new_state.state
# Entity appeared for the first time (old_state=None)
if old_state is None:
_LOGGER.info(
"Trigger entity %s appeared in state machine (state=%s)",
self.entity_id,
new_val,
)
self._logged_unavailable = False
# Capture initial state but don't count as a transition
if new_val not in ("unavailable", "unknown"):
self._last_state = new_val
return
old_val = old_state.state
# Handle unavailable/unknown with log-once pattern
if new_val in ("unavailable", "unknown"):
if not self._logged_unavailable:
_LOGGER.warning(
"Trigger entity %s became %s",
self.entity_id,
new_val,
)
self._logged_unavailable = True
return
# Entity is back to a valid state
if self._logged_unavailable:
_LOGGER.info(
"Trigger entity %s is available again (state=%s)",
self.entity_id,
new_val,
)
self._logged_unavailable = False
# Use _last_state as fallback when old_val is unavailable/unknown
effective_old = old_val
if old_val in ("unavailable", "unknown") and self._last_state is not None:
effective_old = self._last_state
# Check if transition matches pattern
matches = True
if self._from_state is not None and effective_old != self._from_state:
matches = False
if self._to_state is not None and new_val != self._to_state:
matches = False
if matches and effective_old != new_val:
self._change_count += 1
self._current_value = float(self._change_count)
# Persist change count to survive restarts
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
_LOGGER.debug(
"State change counted: %s (%s -> %s) count=%d/%d",
self.entity_id,
old_val,
new_val,
self._change_count,
self._target_changes,
)
was_triggered = self._triggered
is_triggered = self._change_count >= self._target_changes
self._triggered = is_triggered
if is_triggered and not was_triggered:
self._on_trigger_activated(float(self._change_count))
elif not is_triggered and was_triggered:
self._on_trigger_deactivated(float(self._change_count))
self._last_state = new_val
def evaluate(self, value: float) -> bool:
"""Evaluate is handled by _handle_state_transition directly."""
# State change triggers use event-driven evaluation only
return self._triggered
@property
def change_count(self) -> int:
"""Return the current change count."""
return self._change_count
def reset_count(self) -> None:
"""Reset the change counter (after maintenance)."""
self._change_count = 0
self._current_value = 0.0
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
_LOGGER.debug("State change counter reset: %s", self.entity_id)
async def _persist_change_count(self) -> None:
"""Persist change count to the Store for survival across restarts."""
await self._coordinator.async_persist_trigger_runtime(
self._task_id,
{"change_count": self._change_count},
entity_id=self.entity_id,
)
def reset(self) -> None:
"""Reset trigger and counter."""
super().reset()
self.reset_count()
@@ -0,0 +1,176 @@
"""Threshold trigger for maintenance tasks."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
class ThresholdTrigger(BaseTrigger):
"""Trigger that activates when a sensor value exceeds thresholds.
Supports:
- Above threshold (value > above)
- Below threshold (value < below)
- Duration requirement (value must exceed for X minutes)
"""
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
trigger_config: dict[str, Any],
) -> None:
"""Initialize threshold trigger."""
super().__init__(hass, entity, trigger_config)
self._above: float | None = trigger_config.get("trigger_above")
self._below: float | None = trigger_config.get("trigger_below")
self._for_minutes: int = trigger_config.get("trigger_for_minutes", 0)
self._threshold_exceeded = False
self._timer_cancel: CALLBACK_TYPE | None = None
# Restore persisted exceeded-since timestamp (survives HA restarts)
exceeded_since = trigger_config.get("trigger_threshold_exceeded_since")
self._exceeded_since: str | None = None
self._exceeded_since_dt: datetime | None = None
if exceeded_since:
try:
parsed = datetime.fromisoformat(exceeded_since)
# Older payloads may be naive — assume UTC since live writes
# use dt_util.utcnow().isoformat() (TZ-aware).
if parsed.tzinfo is None:
from datetime import UTC
parsed = parsed.replace(tzinfo=UTC)
self._exceeded_since_dt = parsed
self._exceeded_since = exceeded_since
except (ValueError, TypeError):
pass
def _value_exceeds_threshold(self, value: float) -> bool:
"""Check if the value exceeds configured thresholds."""
if self._above is not None and value > self._above:
return True
if self._below is not None and value < self._below:
return True
return False
def evaluate(self, value: float) -> bool:
"""Evaluate threshold condition."""
exceeds = self._value_exceeds_threshold(value)
if exceeds:
if self._for_minutes > 0:
if not self._threshold_exceeded:
self._threshold_exceeded = True
# Restart recovery: check persisted exceeded_since
if self._exceeded_since_dt is not None:
elapsed = (dt_util.utcnow() - self._exceeded_since_dt).total_seconds()
if elapsed >= self._for_minutes * 60:
_LOGGER.debug(
"Threshold recovery: elapsed %.0fs >= %ds, triggering immediately: %s",
elapsed,
self._for_minutes * 60,
self.entity_id,
)
self._triggered = True
return True
remaining = max(self._for_minutes * 60 - elapsed, 0)
_LOGGER.debug(
"Threshold recovery: %.0fs remaining: %s",
remaining,
self.entity_id,
)
self._exceeded_since_dt = None # consumed
self._start_for_timer(remaining_seconds=remaining)
return False
# Fresh start: persist timestamp and start full timer
self._exceeded_since = dt_util.utcnow().isoformat()
self._exceeded_since_dt = None
if self.hass.is_running:
self.hass.async_create_task(self._persist_exceeded_since())
self._start_for_timer()
return False
# Timer running or already triggered
return self._triggered
return True
# Value back in normal range
if self._threshold_exceeded and self._exceeded_since is not None:
self._exceeded_since = None
self._exceeded_since_dt = None
if self.hass.is_running:
self.hass.async_create_task(self._persist_exceeded_since())
self._threshold_exceeded = False
self._cancel_timer()
return False
def _start_for_timer(self, remaining_seconds: float | None = None) -> None:
"""Start the for-duration timer.
If *remaining_seconds* is provided (e.g. after a restart recovery),
the timer uses that value instead of the full ``for_minutes`` duration.
The exceeded-since timestamp is persisted so the timer survives HA
restarts.
"""
self._cancel_timer()
duration = remaining_seconds if remaining_seconds is not None else self._for_minutes * 60
@callback
def _timer_fired(_now: datetime) -> None:
"""Handle timer completion."""
if self._threshold_exceeded:
_LOGGER.debug(
"Threshold for-timer fired: %s (%d min)",
self.entity_id,
self._for_minutes,
)
self._triggered = True
self._on_trigger_activated(self._current_value or 0.0)
self._timer_cancel = async_call_later(self.hass, duration, _timer_fired)
def _cancel_timer(self) -> None:
"""Cancel the for-duration timer."""
if self._timer_cancel is not None:
self._timer_cancel()
self._timer_cancel = None
async def _persist_exceeded_since(self) -> None:
"""Persist exceeded-since timestamp for survival across restarts."""
await self._coordinator.async_persist_trigger_runtime(
self._task_id,
{"threshold_exceeded_since": self._exceeded_since},
entity_id=self.entity_id,
)
async def async_teardown(self) -> None:
"""Clean up timer on teardown."""
self._cancel_timer()
await super().async_teardown()
def reset(self) -> None:
"""Reset trigger state."""
super().reset()
self._threshold_exceeded = False
self._exceeded_since = None
self._exceeded_since_dt = None
if self.hass.is_running:
self.hass.async_create_task(self._persist_exceeded_since())
self._cancel_timer()
@@ -0,0 +1,232 @@
"""Export maintenance data as JSON or YAML."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
)
from .helpers.schedule import Schedule, read_legacy_fields
_LOGGER = logging.getLogger(__name__)
def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]:
"""Export an object's document metadata + web-links (blobs ride the backup).
File binaries are NOT in the JSON export (they live under /config and travel
via the HA backup). An import without a matching backup therefore recreates
file metadata pointing at a missing blob the storage-hygiene repair issue
catches those as dangling. Web-links round-trip fully. task_ids are dropped:
tasks get fresh ids on import, so the links wouldn't resolve.
"""
out: list[dict[str, Any]] = []
for d in doc_store.for_object(object_id):
if d.get("kind") == "weblink":
out.append(
{
"kind": "weblink",
"url": d.get("url"),
"title": d.get("title"),
"tags": d.get("tags") or [],
}
)
else:
out.append(
{
"kind": "file",
"hash": d.get("hash"),
"title": d.get("title"),
"filename": d.get("filename"),
"mime": d.get("mime"),
"size": d.get("size"),
"tags": d.get("tags") or [],
}
)
return out
def _build_export_object(
hass: HomeAssistant,
entry: ConfigEntry,
coordinator_data: dict[str, Any] | None,
include_history: bool,
) -> dict[str, Any]:
"""Build a single object's export dict."""
obj_data = entry.data.get(CONF_OBJECT, {})
# Merge static + Store dynamic data for each task
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
static_tasks = entry.data.get(CONF_TASKS, {})
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
tasks = []
for tid, tdata in tasks_data.items():
ct = ct_tasks.get(tid, {})
sched = read_legacy_fields(tdata)
task: dict[str, Any] = {
"id": tid,
"name": tdata.get("name", ""),
"type": tdata.get("type", "custom"),
"enabled": tdata.get("enabled", True),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"],
"interval_unit": sched["interval_unit"],
"due_date": sched["due_date"],
"interval_anchor": sched["interval_anchor"],
# Nested recurrence — carries the calendar kinds (weekdays /
# nth_weekday / day_of_month) that the flat fields above can't.
"schedule": Schedule.parse(tdata).to_dict(),
"last_planned_due": tdata.get("last_planned_due"),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed"),
"notes": tdata.get("notes"),
"documentation_url": tdata.get("documentation_url"),
"custom_icon": tdata.get("custom_icon"),
"nfc_tag_id": tdata.get("nfc_tag_id"),
"responsible_user_id": tdata.get("responsible_user_id"),
"entity_slug": tdata.get("entity_slug"),
"adaptive_config": tdata.get("adaptive_config"),
"checklist": tdata.get("checklist") or [],
"status": ct.get("_status", "ok"),
"days_until_due": ct.get("_days_until_due"),
"next_due": ct.get("_next_due"),
"times_performed": ct.get("_times_performed", 0),
"total_cost": ct.get("_total_cost", 0.0),
"average_duration": ct.get("_average_duration"),
}
trigger_config = tdata.get("trigger_config")
if trigger_config:
task["trigger_config"] = trigger_config
if include_history:
task["history"] = tdata.get("history") or []
tasks.append(task)
# (roadmap P6) attach document metadata + web-links.
from . import DOCUMENT_STORE_KEY
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
object_id = obj_data.get("id", "")
documents = _export_documents(doc_store, object_id) if doc_store is not None and object_id else []
return {
"entry_id": entry.entry_id,
"object": {
"name": obj_data.get("name", ""),
"area_id": obj_data.get("area_id"),
"manufacturer": obj_data.get("manufacturer"),
"model": obj_data.get("model"),
"serial_number": obj_data.get("serial_number"),
"installation_date": obj_data.get("installation_date"),
"warranty_expiry": obj_data.get("warranty_expiry"),
# Round-tripped so a JSON backup restores the full asset record
# (these were added in v1.4.0/v1.4.10 but missed here until #67).
"documentation_url": obj_data.get("documentation_url"),
"notes": obj_data.get("notes"),
# 2.19: device link / parent hierarchy. Instance-specific ids —
# meaningful when restoring on the SAME instance; dangling values
# on a foreign instance are harmless (device_info falls back).
"ha_device_id": obj_data.get("ha_device_id"),
"parent_entry_id": obj_data.get("parent_entry_id"),
# 2.20: seasonal pause (a paused pool restored in winter stays
# paused) + replace-flow lineage (instance-specific entry ids,
# same caveat as parent_entry_id above).
"paused_at": obj_data.get("paused_at"),
"paused_until": obj_data.get("paused_until"),
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
},
"tasks": tasks,
"documents": documents,
}
def build_export_data(
hass: HomeAssistant,
include_history: bool = True,
) -> dict[str, Any]:
"""Gather all maintenance data into a plain dict.
This must be called from the event loop (accesses HA APIs).
The returned dict contains no HA objects and is safe to
serialize in an executor thread.
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
objects = []
for entry in entries:
rd = getattr(entry, "runtime_data", None)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
objects.append(_build_export_object(hass, entry, coord_data, include_history))
return {
"version": 1,
"objects": objects,
}
def serialize_export(data: dict[str, Any], fmt: str = "json") -> str:
"""Serialize an export data dict to a JSON or YAML string.
Pure function with no HA dependencies safe to run in an executor.
"""
if fmt == "yaml":
try:
import yaml # type: ignore[import-untyped]
# Normalize through JSON first: yaml.safe_dump rejects types the
# JSON path coerces (e.g. tuples → lists), so YAML export would
# crash on data JSON handles fine. Round-tripping keeps both
# formats consistent and YAML-safe.
normalized = json.loads(json.dumps(data, ensure_ascii=False))
return str(yaml.safe_dump(normalized, default_flow_style=False, allow_unicode=True))
except ImportError:
_LOGGER.warning("PyYAML not available, falling back to JSON")
return json.dumps(data, indent=2, ensure_ascii=False)
return json.dumps(data, indent=2, ensure_ascii=False)
def serialize_export_to_file(data: dict[str, Any], fmt: str, file_path: str) -> str:
"""Serialize export data and write to a file.
Pure sync function safe to run in an executor via
``hass.async_add_executor_job``.
Returns:
The file path written to.
"""
content = serialize_export(data, fmt)
Path(file_path).write_text(content, encoding="utf-8")
return file_path
def export_maintenance_data(
hass: HomeAssistant,
fmt: str = "json",
include_history: bool = True,
) -> str:
"""Export all maintenance data as a JSON or YAML string.
Legacy convenience wrapper used by the WebSocket export handler.
For the service handler, prefer ``build_export_data`` +
``serialize_export_to_file`` (via executor) to avoid blocking
the event loop.
"""
data = build_export_data(hass, include_history=include_history)
return serialize_export(data, fmt)
@@ -0,0 +1,105 @@
/** Shared mount helper + data factories for full-panel tests.
*
* Mounting <maintenance-supporter-panel> needs a hass mock with `user` set
* (no user the panel renders read-only "operator" mode) plus handlers for
* the five _loadData calls. Used by panel-shell.test.ts (bulk / palette /
* Today / virtual table) and panel-deeplink.test.ts (QR scan routing).
*/
import { fixture, html } from "@open-wc/testing";
import "../maintenance-panel.js";
import { createMockHass, type WsHandler } from "./_test-utils.js";
let taskSeq = 0;
export function resetTaskSeq(): void {
taskSeq = 0;
}
export function task(over: Record<string, unknown> = {}) {
taskSeq++;
return {
id: `t${taskSeq}`,
name: `Task ${String(taskSeq).padStart(3, "0")}`,
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
status: "ok",
days_until_due: 10,
next_due: "2026-07-15",
last_performed: null,
trigger_active: false,
trigger_current_value: null,
trigger_config: null,
times_performed: 0,
total_cost: 0,
average_duration: null,
history: [],
checklist: [],
labels: [],
priority: "normal",
enabled: true,
archived: false,
is_done: false,
responsible_user_id: null,
nfc_tag_id: null,
entity_slug: null,
...over,
};
}
export function obj(entryId: string, tasks: unknown[], name = "Pool Pump") {
return {
entry_id: entryId,
object_id: `obj_${entryId}`,
object: {
id: `obj_${entryId}`, name, area_id: null, manufacturer: null,
model: null, serial_number: null, task_ids: [],
},
tasks,
document_count: 0,
};
}
export async function mountPanel(
objects: unknown[],
extraHandlers: Record<string, WsHandler> = {},
) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({ objects }),
"maintenance_supporter/statistics": () => ({
total_objects: objects.length, total_tasks: 0,
overdue: 0, due_soon: 0, triggered: 0, ok: 0,
}),
"maintenance_supporter/budget_status": () => ({}),
"maintenance_supporter/groups": () => ({ groups: {} }),
"maintenance_supporter/documents/list": () => ({ documents: [] }),
"maintenance_supporter/task/complete": () => ({ success: true }),
"maintenance_supporter/task/archive": () => ({ success: true }),
"maintenance_supporter/task/unarchive": () => ({ success: true }),
...extraHandlers,
},
});
// The panel derives write access from hass.user (no user → read-only).
(hass as Record<string, unknown>).user = { id: "admin-1", is_admin: true };
(hass as Record<string, unknown>).areas = {};
const el = await fixture<HTMLElement & { updateComplete: Promise<unknown> }>(html`
<maintenance-supporter-panel
.hass=${hass}
style="display:block; height: 600px;"
></maintenance-supporter-panel>
`);
// _loadData is async; give it a beat, then settle renders.
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
return { el, sent };
}
export function sr(el: HTMLElement): ShadowRoot {
return el.shadowRoot!;
}
@@ -0,0 +1,171 @@
/**
* Shared test utilities for the panel/card component test suites.
*
* Centralises the things that were previously duplicated across the per-file
* `mockHass()` helpers in settings-view-vacation.test.ts /
* settings-view-print-qr.test.ts / task-dialog-completion-actions.test.ts:
*
* - DEFAULT_FEATURES the AdvancedFeatures shape with everything OFF
* - DEFAULT_SETTINGS_RESPONSE what the backend's
* `maintenance_supporter/settings` WS handler returns out of the box
* - createMockHass(...) returns a stub `{hass, sent, serviceCalls}`
* with sendMessagePromise/callService captures and a small handler
* map for the most common WS endpoints. Per-suite overrides can be
* added via the `handlers` option.
*
* Underscore-prefixed filename so web-test-runner's `__tests__/**\/*.test.ts`
* glob skips it (it's a helper, not a suite).
*/
export interface SentMessage {
type: string;
[key: string]: unknown;
}
export interface ServiceCall {
domain: string;
service: string;
data?: Record<string, unknown>;
/** v2.3.x separate target arg (matches HA's callService(d, s, data, target)
* signature + the production action_listener.py path). Older tests asserted
* on data.entity_id; that pattern is now legacy assert on target.entity_id. */
target?: Record<string, unknown>;
}
/** Same shape as `frontend-src/types.ts::AdvancedFeatures`. */
export interface MockFeatures {
adaptive: boolean;
predictions: boolean;
seasonal: boolean;
environmental: boolean;
budget: boolean;
groups: boolean;
checklists: boolean;
schedule_time: boolean;
completion_actions: boolean;
}
export const DEFAULT_FEATURES: MockFeatures = {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
};
/**
* Default response for `maintenance_supporter/settings` mirrors the shape
* that `_build_full_settings()` in `websocket/dashboard.py` produces with
* defaults. Override sub-objects via spread when a test needs a specific value:
*
* const settings = { ...DEFAULT_SETTINGS_RESPONSE, vacation: {...DEFAULT_SETTINGS_RESPONSE.vacation, enabled: true} };
*/
export const DEFAULT_SETTINGS_RESPONSE = {
features: { ...DEFAULT_FEATURES },
admin_panel_user_ids: [] as string[],
operator_write_enabled: false,
general: {
default_warning_days: 7,
notifications_enabled: false,
notify_service: "",
notify_targets: [] as string[],
panel_enabled: false,
},
notifications: {
due_soon_enabled: true, due_soon_interval_hours: 24,
overdue_enabled: true, overdue_interval_hours: 12,
triggered_enabled: true, triggered_interval_hours: 0,
quiet_hours_enabled: true, quiet_hours_start: "22:00", quiet_hours_end: "08:00",
max_per_day: 0, bundling_enabled: false, bundle_threshold: 2,
title_style: "default",
},
actions: {
complete_enabled: false, skip_enabled: false,
snooze_enabled: false, snooze_duration_hours: 4,
},
budget: {
monthly: 0, yearly: 0, alerts_enabled: false,
alert_threshold_pct: 80, currency: "EUR", currency_symbol: "€",
},
vacation: {
enabled: false, start: null as string | null, end: null as string | null,
buffer_days: 3, exempt_task_ids: [] as string[],
is_active: false, window_end: null as string | null,
},
};
export type WsHandler = (msg: SentMessage) => Promise<unknown> | unknown;
export interface CreateMockHassResult {
hass: {
language: string;
connection: { sendMessagePromise: (msg: SentMessage) => Promise<unknown> };
callService: (
domain: string, service: string,
data?: Record<string, unknown>, target?: Record<string, unknown>,
) => Promise<void>;
services?: Record<string, Record<string, unknown>>;
states?: Record<string, unknown>;
};
sent: SentMessage[];
serviceCalls: ServiceCall[];
}
export interface CreateMockHassOptions {
/** Override the default settings response (deep-merge not done — pass full shape). */
settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE;
/** Per-WS-type handlers — return a value or Promise. Wins over built-in defaults. */
handlers?: Record<string, WsHandler>;
/** Optional `hass.services` registry (for ha-service-picker / schema-driven forms). */
services?: Record<string, Record<string, unknown>>;
/** Optional `hass.states` (entity_id → state) — e.g. for notify-entity pickers. */
states?: Record<string, unknown>;
/** Override `hass.language`. Defaults to "en". */
language?: string;
}
/**
* Build a stub `hass` object suitable for mounting Lit components in
* @open-wc/testing fixtures. Captures all outgoing WS messages in `sent`
* and all service calls in `serviceCalls` so tests can assert on them.
*
* Built-in handlers (overridable via `opts.handlers`):
* - maintenance_supporter/settings DEFAULT_SETTINGS_RESPONSE (or the override)
* - maintenance_supporter/users/list {users: []}
* - maintenance_supporter/objects {objects: []}
* - maintenance_supporter/tags/list {tags: []}
* - default for anything else {}
*/
export function createMockHass(opts: CreateMockHassOptions = {}): CreateMockHassResult {
const sent: SentMessage[] = [];
const serviceCalls: ServiceCall[] = [];
const settings = opts.settingsResponse ?? DEFAULT_SETTINGS_RESPONSE;
const sendMessagePromise = async (msg: SentMessage): Promise<unknown> => {
sent.push(msg);
const override = opts.handlers?.[msg.type];
if (override) return await override(msg);
if (msg.type === "maintenance_supporter/settings") return settings;
if (msg.type === "maintenance_supporter/users/list") return { users: [] };
if (msg.type === "maintenance_supporter/objects") return { objects: [] };
if (msg.type === "maintenance_supporter/tags/list") return { tags: [] };
return {};
};
const callService = async (
domain: string, service: string,
data?: Record<string, unknown>, target?: Record<string, unknown>,
): Promise<void> => {
serviceCalls.push({ domain, service, data, target });
};
return {
hass: {
language: opts.language ?? "en",
connection: { sendMessagePromise },
callService,
services: opts.services,
states: opts.states,
},
sent,
serviceCalls,
};
}
@@ -0,0 +1,465 @@
/**
* Pure-function tests for the Calendar tab's bucketing + recurring projection
* helper (v1.5.0).
*
* Pins:
* - tasks with next_due in the window land on the right day
* - tasks with next_due before the window are excluded (unless overdue)
* - overdue / triggered tasks bucket on "today" (windowStart)
* - time-based tasks project up to MAX_OCCURRENCES_PER_TASK occurrences
* - sensor-triggered tasks DO NOT get projected occurrences
* - user filter restricts to tasks with matching responsible_user_id
* - status sort within a day: overdue < triggered < due_soon < ok
* - disabled tasks never produce events
*/
import { expect } from "@open-wc/testing";
import {
buildCalendarBuckets,
buildPastBuckets,
buildPastWindowDates,
isoDateLocal,
MAX_OCCURRENCES_PER_TASK,
} from "../helpers/calendar-bucket";
const TODAY = new Date(2026, 4, 1); // 2026-05-01 local
const TODAY_ISO = "2026-05-01";
function addDays(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
date.setDate(date.getDate() + days);
return isoDateLocal(date);
}
function task(over: Partial<any> = {}) {
return {
id: "t1",
name: "Filter Replacement",
enabled: true,
schedule_type: "time_based",
interval_days: 30,
status: "ok",
next_due: addDays(TODAY_ISO, 5),
days_until_due: 5,
history: [],
responsible_user_id: null,
...over,
};
}
function obj(name: string, tasks: any[]) {
return {
entry_id: `entry-${name.toLowerCase().replace(/\s+/g, "-")}`,
object: { id: `o-${name}`, name, area_id: null, manufacturer: null,
model: null, serial_number: null, installation_date: null },
tasks,
} as any;
}
describe("buildCalendarBuckets", () => {
it("returns one bucket per day in the window", () => {
const buckets = buildCalendarBuckets([], TODAY, 7);
expect(buckets).to.have.length(7);
expect(buckets[0].date).to.equal(TODAY_ISO);
expect(buckets[6].date).to.equal(addDays(TODAY_ISO, 6));
});
it("buckets a single task on its next_due day", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 3) })])],
TODAY, 7
);
expect(buckets[3].events).to.have.length(1);
expect(buckets[3].events[0].task_name).to.equal("Filter Replacement");
expect(buckets[3].events[0].projected).to.equal(false);
});
it("excludes tasks whose next_due is before the window and not overdue", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, -10), status: "ok" })])],
TODAY, 7
);
const total = buckets.reduce((s, b) => s + b.events.length, 0);
expect(total).to.equal(0);
});
it("buckets overdue tasks on today regardless of next_due", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
next_due: addDays(TODAY_ISO, -15),
status: "overdue",
days_until_due: -15,
interval_days: 90, // 90d > 7d window → no projection within window
})])],
TODAY, 7
);
expect(buckets[0].events).to.have.length(1);
expect(buckets[0].events[0].status).to.equal("overdue");
expect(buckets[0].events[0].projected).to.equal(false);
});
it("projects time-based recurring occurrences within the window", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, 2),
interval_days: 7,
schedule_type: "time_based",
})])],
TODAY, 30
);
// Window covers days 0..29. From next_due=+2 with step=7: +2, +9, +16, +23.
// Next would be +30 which is outside the window, so 4 events total.
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(4);
expect(events[0].projected).to.equal(false);
expect(events.slice(1).every((e) => e.projected)).to.equal(true);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
expect(events[3].date).to.equal(addDays(TODAY_ISO, 23));
});
it("caps projection at MAX_OCCURRENCES_PER_TASK", () => {
// 1-day interval with 30-day window would otherwise produce 30+ events
const buckets = buildCalendarBuckets(
[obj("Daily", [task({
next_due: TODAY_ISO,
interval_days: 1,
schedule_type: "time_based",
days_until_due: 0,
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(MAX_OCCURRENCES_PER_TASK);
});
it("a months/years interval does not project at daily steps (issue #59)", () => {
// interval_days=1 + unit=years → one occurrence per ~year, so a 30-day
// window holds only the single next_due, NOT 5 daily-spaced projections.
const buckets = buildCalendarBuckets(
[obj("Boiler", [task({
next_due: addDays(TODAY_ISO, 3),
days_until_due: 3,
interval_days: 1,
interval_unit: "years",
schedule_type: "time_based",
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(1);
expect(events[0].projected).to.equal(false);
});
it("projects a weekly (unit=weeks) task at ~7-day steps (#59)", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, 2),
days_until_due: 2,
interval_days: 1,
interval_unit: "weeks",
schedule_type: "time_based",
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
// +2, +9, +16, +23 — same as interval_days:7, NOT daily spam.
expect(events.length).to.equal(4);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
});
it("projects a monthly (unit=months) task at ~30-day steps (#59)", () => {
const buckets = buildCalendarBuckets(
[obj("Filter", [task({
next_due: addDays(TODAY_ISO, 3),
days_until_due: 3,
interval_days: 1,
interval_unit: "months",
schedule_type: "time_based",
})])],
TODAY, 70
);
const events = buckets.flatMap((b) => b.events);
// round(30.4368) = 30 → +3, +33, +63 within a 70-day window.
expect(events.length).to.equal(3);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 3));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 33));
});
it("does NOT project sensor-triggered tasks beyond next_due", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
schedule_type: "sensor_based",
interval_days: 7, // ignored — sensor tasks don't project
next_due: addDays(TODAY_ISO, 3),
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].projected).to.equal(false);
});
it("respects the user filter via responsible_user_id", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-none", responsible_user_id: null, next_due: addDays(TODAY_ISO, 1) }),
])],
TODAY, 7, "alice"
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].task_id).to.equal("t-alice");
});
it("includes all users when filter is null", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
])],
TODAY, 7, null
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(2);
});
it("orders within a day: overdue < triggered < due_soon < ok", () => {
const due = addDays(TODAY_ISO, 0);
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-ok", status: "ok", name: "OK Task", next_due: due, interval_days: 9999 }),
task({ id: "t-overdue", status: "overdue", name: "Overdue Task",
next_due: due, days_until_due: -5, interval_days: 9999 }),
task({ id: "t-triggered", status: "triggered", name: "Triggered Task",
schedule_type: "sensor_based", next_due: due, interval_days: null }),
task({ id: "t-due_soon", status: "due_soon", name: "Due Soon Task",
next_due: due, interval_days: 9999 }),
])],
TODAY, 7
);
const todayEvents = buckets[0].events;
expect(todayEvents.map((e) => e.status)).to.deep.equal([
"overdue", "triggered", "due_soon", "ok",
]);
});
it("excludes disabled tasks", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ enabled: false, next_due: addDays(TODAY_ISO, 1) })])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(0);
});
it("buckets first occurrence + projected ones for a 14-day window task", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({ next_due: addDays(TODAY_ISO, 1), interval_days: 14 })])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(3); // +1, +15, +29
expect(events[0].date).to.equal(addDays(TODAY_ISO, 1));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 15));
expect(events[2].date).to.equal(addDays(TODAY_ISO, 29));
});
// v1.5.1: source indicator + prediction confidence
it("flags adaptive_enabled when adaptive_config.enabled is true", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
next_due: addDays(TODAY_ISO, 5),
adaptive_config: { enabled: true },
})])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].adaptive_enabled).to.equal(true);
expect(events[0].prediction_confidence).to.equal(null);
});
it("propagates threshold_prediction_confidence for sensor-based tasks", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
schedule_type: "sensor_based",
next_due: addDays(TODAY_ISO, 4),
threshold_prediction_confidence: "high",
interval_days: null,
})])],
TODAY, 14
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].schedule_type).to.equal("sensor_based");
expect(events[0].prediction_confidence).to.equal("high");
expect(events[0].adaptive_enabled).to.equal(false);
});
it("downgrades status of projected occurrences from an overdue task to ok", () => {
// The May 7 projection of an overdue task should NOT inherit "overdue"
// — the projection is the assumption that the user completes today, so
// the projected slot is hypothetical and starts fresh.
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, -10),
status: "overdue",
days_until_due: -10,
interval_days: 7,
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.be.greaterThan(1);
expect(events[0].status).to.equal("overdue"); // first occurrence on today
expect(events[0].projected).to.equal(false);
// All projected occurrences should read "ok"
for (const e of events.slice(1)) {
expect(e.projected).to.equal(true);
expect(e.status).to.equal("ok");
expect(e.days_until_due).to.equal(null);
}
});
it("defaults source fields to (false, null) when no metadata is present", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 2) })])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].adaptive_enabled).to.equal(false);
expect(events[0].prediction_confidence).to.equal(null);
});
});
// ── v2.2.0 — past-window bucketer ────────────────────────────────────────
function histEntry(daysAgo: number, type = "completed", over: Partial<any> = {}) {
const d = new Date(TODAY);
d.setDate(d.getDate() - daysAgo);
return {
timestamp: `${isoDateLocal(d)}T08:30:00`,
type,
...over,
};
}
function pastTask(history: any[], over: Partial<any> = {}) {
return {
...task({ history }),
...over,
};
}
describe("buildPastWindowDates", () => {
it("returns N consecutive ISO dates ending on today", () => {
const dates = buildPastWindowDates(TODAY, 7);
expect(dates).to.have.length(7);
expect(dates[6]).to.equal(TODAY_ISO);
expect(dates[0]).to.equal(addDays(TODAY_ISO, -6));
});
});
describe("buildPastBuckets", () => {
it("returns one bucket per day in the past window", () => {
const buckets = buildPastBuckets([], TODAY, 30);
expect(buckets).to.have.length(30);
expect(buckets[29].date).to.equal(TODAY_ISO);
expect(buckets[0].date).to.equal(addDays(TODAY_ISO, -29));
});
it("buckets a completion entry on its actual date", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([histEntry(5)])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].date).to.equal(addDays(TODAY_ISO, -5));
expect(events[0].history_timestamp).to.match(/^[0-9-]{10}T08:30:00$/);
expect(events[0].history_type).to.equal("completed");
});
it("excludes entries outside the window", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(5), // inside 30-day window
histEntry(40), // outside
])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
});
it("preserves cost / notes / duration from history entries", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(3, "completed", { cost: 42.5, notes: "n1", duration: 60 }),
])])],
TODAY, 30,
);
const ev = buckets.flatMap((b) => b.events)[0];
expect(ev.history_cost).to.equal(42.5);
expect(ev.history_notes).to.equal("n1");
expect(ev.history_duration).to.equal(60);
});
it("maps history types to status correctly", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(1, "completed"),
histEntry(2, "skipped"),
histEntry(3, "triggered"),
])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
const byType = Object.fromEntries(events.map((e) => [e.history_type, e.status]));
expect(byType.completed).to.equal("ok");
expect(byType.skipped).to.equal("due_soon");
expect(byType.triggered).to.equal("triggered");
});
it("respects user filter", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [
pastTask([histEntry(5)], { id: "a", responsible_user_id: "u1" }),
pastTask([histEntry(5)], { id: "b", responsible_user_id: "u2" }),
])],
TODAY, 30, "u1",
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].task_id).to.equal("a");
});
it("sorts within day by type rank then by name", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [
pastTask([histEntry(5, "triggered")], { id: "z", name: "Z task" }),
pastTask([histEntry(5, "completed")], { id: "a", name: "A task" }),
pastTask([histEntry(5, "completed")], { id: "m", name: "M task" }),
])],
TODAY, 30,
);
const dayEvents = buckets.find((b) => b.date === addDays(TODAY_ISO, -5))!.events;
expect(dayEvents.map((e) => e.task_id)).to.deep.equal(["a", "m", "z"]);
});
it("history_timestamp survives so the edit-history WS can use it", () => {
const ts = "2026-04-25T14:00:00";
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([{ timestamp: ts, type: "completed" }])])],
TODAY, 30,
);
const ev = buckets.flatMap((b) => b.events)[0];
expect(ev.history_timestamp).to.equal(ts);
});
});
@@ -0,0 +1,120 @@
/**
* Tests for the standalone <maintenance-supporter-calendar-card> (audit #10).
*
* The bucketing math is deep-tested in calendar-bucket.test.ts; this covers
* the card behaviours on top of it:
* - window chips re-bucket the events (an event beyond the window disappears)
* - projected recurrences render with the projected class; the real
* next-due event does not
* - clicking an event fires the ll-custom open-task payload the panel and
* dialog-mount listen for
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../maintenance-calendar-card.js";
import { createMockHass } from "./_test-utils.js";
type CardEl = HTMLElement & { updateComplete: Promise<unknown> };
function isoInDays(n: number): string {
const d = new Date();
d.setDate(d.getDate() + n);
const p = (x: number) => String(x).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
function task(over: Record<string, unknown> = {}) {
return {
id: "t1",
name: "Filter",
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
status: "ok",
days_until_due: 5,
next_due: isoInDays(5),
trigger_active: false,
history: [],
enabled: true,
archived: false,
responsible_user_id: null,
...over,
};
}
async function mount(tasks: unknown[]) {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({
objects: [{
entry_id: "e1",
object: { id: "o1", name: "Pool Pump", area_id: null, task_ids: [] },
tasks,
}],
}),
"maintenance_supporter/statistics": () => ({}),
},
});
const el = await fixture<CardEl>(html`
<maintenance-supporter-calendar-card .hass=${hass}></maintenance-supporter-calendar-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el };
}
function eventTitles(el: CardEl): string[] {
return [...el.shadowRoot!.querySelectorAll(".cal-event-title")]
.map((e) => e.textContent?.trim() || "");
}
describe("calendar-card", () => {
it("window chips re-bucket: a 20-days-out event survives +30d but not +7d", async () => {
const { el } = await mount([
task({ id: "near", name: "Near", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
task({ id: "far", name: "Far", days_until_due: 20, next_due: isoInDays(20), interval_days: 400 }),
]);
// Default +30d window shows both.
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.true;
// Click the +7d chip → the 20-days-out event drops off.
const chip = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".cal-window-chip")]
.find((c) => c.textContent?.trim() === "+7d")!;
chip.click();
await el.updateComplete;
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.false;
});
it("projected recurrences carry the projected class; the real event does not", async () => {
// 10-day interval inside a 30-day window → 1 real + projected occurrences.
const { el } = await mount([
task({ id: "rec", name: "Recurring", days_until_due: 3, next_due: isoInDays(3), interval_days: 10 }),
]);
const real = [...el.shadowRoot!.querySelectorAll(".cal-event:not(.cal-event-projected)")];
const projected = [...el.shadowRoot!.querySelectorAll(".cal-event.cal-event-projected")];
expect(real.length).to.equal(1);
expect(projected.length).to.be.greaterThan(0);
});
it("clicking an event fires the ll-custom open-task payload", async () => {
const { el } = await mount([
task({ id: "t42", name: "Clicky", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
]);
let detail: Record<string, unknown> | null = null;
el.addEventListener("ll-custom", (e) => {
detail = (e as CustomEvent<Record<string, unknown>>).detail;
});
el.shadowRoot!.querySelector<HTMLElement>(".cal-event")!.click();
expect(detail, "ll-custom fired").to.not.be.null;
expect(detail!.type).to.equal("maintenance-supporter:open-task");
expect(detail!.entry_id).to.equal("e1");
expect(detail!.task_id).to.equal("t42");
});
});
@@ -0,0 +1,86 @@
/**
* Component test for the Lovelace card task sort (forum thread /995556 #7,
* reported by brunkj v2.3.7).
*
* Pins:
* - Tasks sort by status first (overdue, triggered, due_soon, ok)
* - Within a status, soonest-due-first (the bug: previously kept WS/creation
* order, so a task due in 3 days could sit above one due in 1 day)
* - Tasks without a numeric due date sort last within their status
*/
import { expect, fixture, html, waitUntil } from "@open-wc/testing";
import "../maintenance-card.js";
import type { MaintenanceSupporterCard } from "../maintenance-card";
const T = (id: string, name: string, status: string, days: number | null) => ({
id, name, status, days_until_due: days, type: "service",
});
const O = (entry_id: string, name: string, tasks: unknown[]) => ({
entry_id, object: { id: entry_id, name }, tasks,
});
// Creation order deliberately NOT in due-date order, to prove the sort
// no longer falls back to insertion order for same-status ties.
function mockObjects() {
return [
O("e1", "Aqua Pool", [T("t1", "Check chlorine", "due_soon", 3)]),
O("e2", "Hot Tub", [T("t2", "Check pH", "due_soon", 1)]),
O("e3", "Furnace", [
T("t3", "Replace filter", "overdue", -2),
T("t4", "Deep service", "overdue", -10),
]),
O("e4", "Garage", [T("t5", "Manual check", "due_soon", null)]),
O("e5", "Lamp", [T("t6", "Dust", "ok", 40)]),
];
}
function mockHass() {
return {
language: "en",
connection: {
sendMessagePromise: async (msg: { type: string }) =>
msg.type === "maintenance_supporter/objects"
? { objects: mockObjects() }
: { overdue: 0, due_soon: 0, triggered: 0, ok: 0, total: 0 },
subscribeMessage: async () => () => {},
},
};
}
async function mount(config: Record<string, unknown> = {}): Promise<MaintenanceSupporterCard> {
const el = await fixture<MaintenanceSupporterCard>(
html`<maintenance-supporter-card .hass=${mockHass() as never}></maintenance-supporter-card>`
);
el.setConfig({ type: "custom:maintenance-supporter-card", show_actions: false, ...config } as never);
await waitUntil(
() => el.shadowRoot!.querySelectorAll(".task-name").length > 0,
"task rows render",
{ timeout: 2000 }
);
await el.updateComplete;
return el;
}
const names = (el: MaintenanceSupporterCard) =>
[...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || "");
describe("maintenance-card sort (forum #7 — due-date tiebreaker)", () => {
it("orders by status, then soonest-due first within a status", async () => {
const el = await mount();
expect(names(el)).to.deep.equal([
"Deep service", // overdue -10 (most overdue first)
"Replace filter", // overdue -2
"Check pH", // due_soon 1
"Check chlorine", // due_soon 3
"Manual check", // due_soon null -> last within due_soon
"Dust", // ok 40
]);
});
it("reported case: due-in-1-day sorts before due-in-3-days (same status)", async () => {
const el = await mount({ filter_status: ["due_soon"] });
const n = names(el);
expect(n.indexOf("Check pH")).to.be.lessThan(n.indexOf("Check chlorine"));
});
});
@@ -0,0 +1,169 @@
/**
* Behavioural tests for <maintenance-complete-dialog> (audit gap #3).
*
* The completion dialog is the money path of the whole product; until now it
* was only covered by the lazy-load tripwire. These tests pin the exact
* outgoing WS payload (notes / cost / duration / feedback / checklist_state /
* photo_doc_id) and the error path (server refusal keeps the dialog open).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/complete-dialog.js";
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
import { createMockHass } from "./_test-utils.js";
type MountOpts = {
checklist?: string[];
adaptiveEnabled?: boolean;
completeHandler?: () => unknown;
};
async function mount(opts: MountOpts = {}) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/complete":
opts.completeHandler ?? (() => ({ success: true })),
},
});
const el = await fixture<MaintenanceCompleteDialog>(html`
<maintenance-complete-dialog
.hass=${hass}
.entryId=${"entry1"}
.taskId=${"task1"}
.taskName=${"Filter Wechsel"}
.lang=${"en"}
.checklist=${opts.checklist ?? []}
.adaptiveEnabled=${opts.adaptiveEnabled ?? false}
></maintenance-complete-dialog>
`);
el.open();
await el.updateComplete;
return { el, sent };
}
function setInput(el: MaintenanceCompleteDialog, index: number, value: string) {
const input = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][index];
input.value = value;
input.dispatchEvent(new Event("input"));
}
function clickComplete(el: MaintenanceCompleteDialog) {
const buttons = [...el.shadowRoot!.querySelectorAll(".dialog-actions ha-button")];
(buttons[buttons.length - 1] as HTMLElement).click();
}
describe("complete-dialog", () => {
it("submits notes, cost, duration, feedback and checklist_state in the payload", async () => {
const { el, sent } = await mount({
checklist: ["Step A", "Step B"],
adaptiveEnabled: true,
});
setInput(el, 0, "oil changed");
setInput(el, 1, "12.5");
setInput(el, 2, "30");
// Tick the second checklist step (click the checkbox; the event bubbles
// to the row's toggle handler exactly once).
const boxes = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".checklist-item input")];
boxes[1].click();
// Pick the "not needed" feedback option.
const fb = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".feedback-btn")];
fb[1].click();
await el.updateComplete;
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg, "task/complete sent").to.exist;
expect(msg.entry_id).to.equal("entry1");
expect(msg.task_id).to.equal("task1");
expect(msg.notes).to.equal("oil changed");
expect(msg.cost).to.equal(12.5);
expect(msg.duration).to.equal(30);
expect(msg.feedback).to.equal("not_needed");
expect(msg.checklist_state).to.deep.equal({ "1": true });
// Dialog closed on success.
await el.updateComplete;
expect(el.shadowRoot!.querySelector("ha-dialog")).to.be.null;
});
it("omits optional fields that were left empty", async () => {
const { el, sent } = await mount();
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg).to.exist;
expect("notes" in msg).to.be.false;
expect("cost" in msg).to.be.false;
expect("duration" in msg).to.be.false;
expect("feedback" in msg).to.be.false;
expect("checklist_state" in msg).to.be.false;
expect("photo_doc_id" in msg).to.be.false;
});
it("attaches an uploaded photo as photo_doc_id", async () => {
const { el, sent } = await mount();
// Stub the document-upload endpoint the photo picker posts to.
const realFetch = window.fetch;
const uploads: RequestInit[] = [];
window.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
uploads.push(init!);
return {
ok: true,
status: 200,
json: async () => ({ id: "doc-photo-1", deduped: false }),
} as Response;
}) as typeof window.fetch;
try {
const fileInput = el.shadowRoot!.querySelector<HTMLInputElement>(
'.photo-pick input[type="file"]',
)!;
const dt = new DataTransfer();
dt.items.add(new File(["fake-png"], "done.png", { type: "image/png" }));
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
// Preview replaces the picker once the upload returns an id.
expect(el.shadowRoot!.querySelector(".photo-preview img")).to.exist;
expect(uploads.length).to.equal(1);
const form = uploads[0].body as FormData;
expect(form.get("entry_id")).to.equal("entry1");
expect(form.get("tags")).to.equal("photo");
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg.photo_doc_id).to.equal("doc-photo-1");
} finally {
window.fetch = realFetch;
}
});
it("shows the server error and stays open on refusal (e.g. too_early)", async () => {
const { el, sent } = await mount({
completeHandler: () => {
throw { code: "too_early", message: "Task can only be completed closer to its due date" };
},
});
let completedEvent = false;
el.addEventListener("task-completed", () => (completedEvent = true));
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(sent.find((m) => m.type === "maintenance_supporter/task/complete")).to.exist;
// Dialog stays open with a visible error; no completion event fired.
expect(el.shadowRoot!.querySelector("ha-dialog")).to.exist;
const err = el.shadowRoot!.querySelector(".error");
expect(err, "error banner rendered").to.exist;
expect((err!.textContent || "").length).to.be.greaterThan(0);
expect(completedEvent).to.be.false;
});
});
@@ -0,0 +1,164 @@
/**
* Tripwire: NO dialog mounted via dialog-mount may use HA's lazy-loaded
* elements (`ha-textfield`, `ha-textarea`, `ha-entity-picker`) in its
* shadow DOM. These elements aren't reliably registered in the panel-
* custom or Lovelace contexts, render as HTMLUnknownElement with
* offsetHeight=0, and look like an empty form to the user.
*
* History:
* - #50: complete-dialog notes/cost/duration invisible
* Fix: native <input> with field-input class
* - #50 follow-up: task-dialog target-entity invisible
* Fix: <ha-form> with entity selector schema
* - #46 follow-up: object-dialog name/manufacturer/model/serial/url/notes
* invisible (only ha-area-picker rendered user could "only change
* the area")
* Fix: <ms-textfield> wrapper + native <textarea> for notes
*
* Allowed elements (verified to lazy-load reliably or wrappable):
* - ha-area-picker, ha-form, ha-service-picker, ha-icon, ha-svg-icon,
* ha-button, ha-dialog, ha-icon-button, ha-switch, mwc-button,
* mwc-icon-button, ha-list-item, mwc-list-item
*
* If you need a textfield, use <ms-textfield> (components/ms-textfield.ts).
* If you need an entity picker, use <ha-form> with
* `selector: { entity: {...} }` schema ha-form lazy-loads its child
* pickers internally + IS itself reliably registered.
*
* This test mounts each dialog component and walks its shadow DOM
* looking for any banned tag. If you add a new dialog, import + mount
* it here too.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/object-dialog.js";
import "../components/task-dialog.js";
import "../components/group-dialog.js";
import "../components/complete-dialog.js";
import "../components/history-edit-dialog.js";
import "../components/qr-dialog.js";
import "../components/seasonal-overrides-dialog.js";
import "../components/object-quick-actions-dialog.js";
import "../components/task-quick-actions-dialog.js";
import "../components/confirm-dialog.js";
import { createMockHass } from "./_test-utils.js";
const BANNED_TAGS = ["ha-textfield", "ha-textarea", "ha-entity-picker"];
function findBannedTags(root: ShadowRoot | Element): string[] {
const banned: string[] = [];
for (const tag of BANNED_TAGS) {
const matches = root.querySelectorAll(tag);
matches.forEach((el) => {
banned.push(`<${tag}> at depth ${depthOf(el, root)} (label="${el.getAttribute("label") || ""}")`);
});
}
return banned;
}
function depthOf(el: Element, root: ShadowRoot | Element): number {
let depth = 0;
let cur: ParentNode | null = el.parentNode;
while (cur && cur !== root) {
depth++;
cur = cur.parentNode;
}
return depth;
}
describe("dialog tripwire: no lazy-loaded HA elements", () => {
it("object-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & { hass: unknown; openEdit: (e: string, o: unknown) => void }>(
html`<maintenance-object-dialog .hass=${hass}></maintenance-object-dialog>`,
);
el.openEdit("entry_x", {
id: "obj_1", name: "Test", area_id: "garage",
manufacturer: "ACME", model: "X1", serial_number: "SN1",
installation_date: "2025-01-01", documentation_url: "https://x.test/",
notes: "test notes",
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `object-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("task-dialog with all feature flags on (worst case = most fields rendered)", async () => {
const { hass } = createMockHass({
services: { button: { press: {} } },
});
const el = await fixture<HTMLElement & {
hass: unknown; openEdit: (e: string, t: unknown) => Promise<void>;
checklistsEnabled: boolean; scheduleTimeEnabled: boolean; completionActionsEnabled: boolean;
}>(
html`<maintenance-task-dialog
.hass=${hass}
.checklistsEnabled=${true}
.scheduleTimeEnabled=${true}
.completionActionsEnabled=${true}
></maintenance-task-dialog>`,
);
await el.openEdit("entry_x", {
id: "t1", name: "Test Task", type: "custom", schedule_type: "time_based",
interval_days: 30, warning_days: 7, enabled: true,
checklist: ["step 1"],
schedule_time: "09:00",
on_complete_action: { service: "button.press", target: { entity_id: "button.x" } },
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
// Expand <details> sections so action UI is in the DOM
el.shadowRoot!.querySelectorAll<HTMLDetailsElement>("details").forEach((d) => { d.open = true; });
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `task-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("group-dialog", async () => {
const { hass } = createMockHass({
handlers: { "maintenance_supporter/objects": () => ({ objects: [] }) },
});
const el = await fixture<HTMLElement & { hass: unknown; openCreate: () => void }>(
html`<maintenance-group-dialog .hass=${hass}></maintenance-group-dialog>`,
);
el.openCreate();
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `group-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("complete-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & {
hass: unknown; entryId: string; taskId: string; taskName: string; open: () => void;
}>(
html`<maintenance-complete-dialog .hass=${hass}></maintenance-complete-dialog>`,
);
el.entryId = "entry_x"; el.taskId = "t1"; el.taskName = "Test";
el.open();
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `complete-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("history-edit-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & {
hass: unknown; openEdit: (d: unknown) => void;
}>(
html`<maintenance-history-edit-dialog .hass=${hass}></maintenance-history-edit-dialog>`,
);
el.openEdit({
entry_id: "e", task_id: "t", original_timestamp: "2025-01-01T00:00:00",
type: "completed", timestamp: "2025-01-01T00:00:00",
notes: null, cost: null, duration: null, completed_by: null,
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `history-edit-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
});
@@ -0,0 +1,216 @@
/**
* Lit component tests for <maintenance-documents-section>.
*
* Covers the WS-driven paths (list render, add-link, delete) and the write
* gate. File upload goes through fetch() to the authenticated view and is
* verified live (live_docs.py), not here.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/documents-section.js";
import type { MaintenanceDocumentsSection } from "../components/documents-section";
import { createMockHass } from "./_test-utils.js";
const FILE_DOC = {
id: "d1", kind: "file", title: "Manual", filename: "m.pdf",
mime: "application/pdf", size: 2048, tags: ["manual"], added_at: "2026-01-01T00:00:00",
};
const LINK_DOC = {
id: "d2", kind: "weblink", title: "Online", url: "https://example.com/x",
tags: [], added_at: "2026-01-01T00:00:00",
};
async function mount(canWrite = true, docs: unknown[] = [FILE_DOC, LINK_DOC]) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: docs }),
"maintenance_supporter/documents/add_link": () => ({ id: "new", kind: "weblink", url: "https://x", tags: [] }),
"maintenance_supporter/documents/delete": () => ({ success: true, bytes_freed: 0 }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${canWrite}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el, sent };
}
describe("documents-section", () => {
it("lists documents with a count", async () => {
const { el } = await mount();
const rows = el.shadowRoot!.querySelectorAll(".doc-row");
expect(rows.length).to.equal(2);
expect(el.shadowRoot!.querySelector("h3")!.textContent).to.contain("2");
});
it("offers all six categories when writable", async () => {
const { el } = await mount(true);
const opts = el.shadowRoot!.querySelectorAll(".cat-select option");
expect(opts.length).to.equal(6);
});
it("adds a web-link via WS", async () => {
const { el, sent } = await mount();
const toggle = [...el.shadowRoot!.querySelectorAll("button")].find((b) =>
/link/i.test(b.textContent || ""),
);
toggle!.click();
await el.updateComplete;
const urlInput = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="url"]')!;
urlInput.value = "https://example.com/manual.pdf";
urlInput.dispatchEvent(new Event("input"));
await el.updateComplete;
el.shadowRoot!.querySelector<HTMLButtonElement>(".link-form .btn.primary")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/add_link");
expect(msg, "add_link WS sent").to.exist;
expect(msg!.url).to.equal("https://example.com/manual.pdf");
});
it("deletes a document via WS after confirmation", async () => {
const orig = window.confirm;
window.confirm = () => true;
try {
const { el, sent } = await mount();
el.shadowRoot!.querySelector<HTMLButtonElement>(".icon-btn.danger")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/delete");
expect(msg, "delete WS sent").to.exist;
expect(msg!.doc_id).to.equal("d1");
} finally {
window.confirm = orig;
}
});
it("hides write controls when not writable", async () => {
const { el } = await mount(false);
expect(el.shadowRoot!.querySelector(".cat-select"), "no category select").to.not.exist;
expect(el.shadowRoot!.querySelector(".icon-btn.danger"), "no delete button").to.not.exist;
// read-only still lists documents and offers open/download
expect(el.shadowRoot!.querySelectorAll(".doc-row").length).to.equal(2);
});
it("shows an image thumbnail and opens it in a lightbox", async () => {
const IMG = {
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
};
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const thumb = el.shadowRoot!.querySelector<HTMLImageElement>(".doc-thumb");
expect(thumb, "thumbnail rendered").to.exist;
expect(thumb!.src).to.contain("authSig");
thumb!.click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".lightbox"), "lightbox open").to.exist;
});
it("opens a document by clicking its title row, not just the icons", async () => {
const IMG = {
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
};
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const info = el.shadowRoot!.querySelector<HTMLElement>(".doc-info");
expect(info, "title row present").to.exist;
expect(info!.getAttribute("role"), "title row is a button").to.equal("button");
info!.click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".lightbox"), "clicking the title opens the preview").to.exist;
});
it("edits a document's title/category via WS", async () => {
const { el, sent } = await mount();
const editBtn = [...el.shadowRoot!.querySelectorAll(".doc-row-actions .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:pencil"]'),
) as HTMLButtonElement;
editBtn.click();
await el.updateComplete;
const title = el.shadowRoot!.querySelector<HTMLInputElement>(".edit-title");
expect(title, "edit form open").to.exist;
title!.value = "Renamed manual";
title!.dispatchEvent(new Event("input"));
await el.updateComplete;
const saveBtn = [...el.shadowRoot!.querySelectorAll(".doc-row.editing .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:check"]'),
) as HTMLButtonElement;
saveBtn.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update");
expect(msg, "update WS sent").to.exist;
expect(msg!.title).to.equal("Renamed manual");
expect(msg!.doc_id).to.equal("d1");
});
it("uploads multiple files and honors the camera category override", async () => {
const { el } = await mount(true, [FILE_DOC]);
(el.hass as unknown as { auth: unknown }).auth = { data: { access_token: "tok" } };
const tags: string[] = [];
const origFetch = window.fetch;
window.fetch = (async (_url: string, opts: { body: FormData }) => {
tags.push(opts.body.get("tags") as string);
return new Response(JSON.stringify({ id: "n", deduped: false }), { status: 200 });
}) as unknown as typeof window.fetch;
const up = (el as unknown as { _uploadFiles: (f: File[], c?: string) => Promise<void> })._uploadFiles;
try {
const f1 = new File(["a"], "a.pdf", { type: "application/pdf" });
const f2 = new File(["b"], "b.pdf", { type: "application/pdf" });
await up.call(el, [f1, f2]); // one POST per file, default category
expect(tags).to.deep.equal(["manual", "manual"]);
tags.length = 0;
await up.call(el, [f1], "photo"); // camera override
expect(tags).to.deep.equal(["photo"]);
} finally {
window.fetch = origFetch;
}
});
it("opens the file picker via keyboard on the upload label (a11y)", async () => {
const { el } = await mount();
const label = [...el.shadowRoot!.querySelectorAll("label.btn")].find(
(l) => l.querySelector('input[type="file"][multiple]'),
) as HTMLElement;
expect(label, "upload label is focusable").to.exist;
expect(label.getAttribute("tabindex")).to.equal("0");
expect(label.getAttribute("role")).to.equal("button");
const input = label.querySelector<HTMLInputElement>('input[type="file"]')!;
let clicked = false;
input.click = () => { clicked = true; };
label.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(clicked, "Enter triggers the hidden file input").to.be.true;
});
});
@@ -0,0 +1,61 @@
/**
* downloadTextFile must produce a Companion-app-safe download: a `target=_blank`
* anchor with the right `download` name, appended to the DOM, and the blob URL
* must NOT be revoked synchronously (an immediate revoke cancels the async
* download in the HA Companion app's WebView).
*/
import { expect } from "@open-wc/testing";
import { downloadTextFile } from "../helpers/download";
describe("downloadTextFile — Companion-app safe download", () => {
let created: number;
let revoked: number;
let captured: { download: string; target: string; href: string } | null;
let origCreate: typeof URL.createObjectURL;
let origRevoke: typeof URL.revokeObjectURL;
let origAppend: typeof document.body.appendChild;
beforeEach(() => {
created = 0;
revoked = 0;
captured = null;
origCreate = URL.createObjectURL;
origRevoke = URL.revokeObjectURL;
origAppend = document.body.appendChild.bind(document.body);
URL.createObjectURL = () => { created++; return "blob:fake-url"; };
URL.revokeObjectURL = () => { revoked++; };
document.body.appendChild = ((node: Node) => {
if (node instanceof HTMLAnchorElement) {
captured = { download: node.download, target: node.target, href: node.href };
node.dispatchEvent = () => true; // suppress real navigation in the test
}
return origAppend(node as never);
}) as typeof document.body.appendChild;
});
afterEach(() => {
URL.createObjectURL = origCreate;
URL.revokeObjectURL = origRevoke;
document.body.appendChild = origAppend;
});
it("uses a target=_blank anchor with the given filename, appended to the DOM", () => {
downloadTextFile("a,b\n1,2", "objects.csv", "text/csv");
expect(created).to.equal(1);
expect(captured).to.not.equal(null);
expect(captured!.download).to.equal("objects.csv");
expect(captured!.target).to.equal("_blank");
expect(captured!.href).to.equal("blob:fake-url");
});
it("does NOT revoke the blob URL synchronously", () => {
downloadTextFile("x", "x.csv", "text/csv");
expect(revoked).to.equal(0);
});
it("cleans up the temporary anchor", () => {
const before = document.body.querySelectorAll("a").length;
downloadTextFile("x", "x.csv", "text/csv");
expect(document.body.querySelectorAll("a").length).to.equal(before);
});
});
@@ -0,0 +1,39 @@
/** Tests for the chart outlier filter (IQR fence). */
import { expect } from "@open-wc/testing";
import { filterOutliers } from "../renderers/sparkline.js";
import type { ChartPoint } from "../components/trigger-chart";
const pts = (vals: number[]): ChartPoint[] =>
vals.map((val, i) => ({ ts: i * 1000, val }));
describe("filterOutliers", () => {
it("drops a wild glitch reading (pressure 1.53 → 100)", () => {
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 100, 1.7, 2.3, 1.5]);
const out = filterOutliers(input);
expect(out.map((p) => p.val)).to.not.include(100);
expect(out.length).to.equal(input.length - 1);
});
it("keeps a normal spread untouched", () => {
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 1.7, 2.3, 1.5, 2.0]);
expect(filterOutliers(input).length).to.equal(input.length);
});
it("no-ops on short series (< 4 points)", () => {
const input = pts([1, 100, 2]);
expect(filterOutliers(input).length).to.equal(3);
});
it("never strips below a drawable series", () => {
// Two extreme values, everything else identical → IQR fence could nuke both
// ends, but we must keep at least 2 points.
const input = pts([5, 5, 5, 5, 5, 5, 999, -999]);
expect(filterOutliers(input).length).to.be.greaterThan(1);
});
it("returns the series unchanged when all values are identical (IQR 0)", () => {
const input = pts([3, 3, 3, 3, 3]);
expect(filterOutliers(input).length).to.equal(5);
});
});
@@ -0,0 +1,66 @@
/** Unit tests for formatRecurrence the single recurrence label for every
* schedule kind (Phase 4 calendar kinds). Weekday names come from Intl. */
import { expect } from "@open-wc/testing";
import { formatRecurrence, setLocale } from "../styles";
import de from "../locales/de.json";
describe("formatRecurrence", () => {
// German strings are runtime-loaded (not bundled); seed the real table so the
// localized-ordinal case exercises actual German, not the EN fallback.
before(() => setLocale("de", de as Record<string, string>));
it("interval → '6 Months'", () => {
expect(formatRecurrence({ schedule: { kind: "interval", every: 6, unit: "months" } }, "en")).to.equal("6 Months");
});
it("nth_weekday → '1st Saturday'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "en")).to.equal("1st Saturday");
});
it("nth_weekday last → 'Last Saturday'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: -1, weekday: 5 } }, "en")).to.equal("Last Saturday");
});
it("weekdays → 'Mon & Thu'", () => {
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [0, 3] } }, "en")).to.equal("Mon & Thu");
});
it("day_of_month → 'Day 15'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: 15 } }, "en")).to.equal("Day 15");
});
it("(#83) day -1 → 'Last day of month'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1 } }, "en"))
.to.equal("Last day of month");
});
it("(#83) day -1 + business → 'Last business day'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1, business: true } }, "en"))
.to.equal("Last business day");
});
it("(#83) negative offset renders as a Nd suffix", () => {
expect(formatRecurrence(
{ schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 } }, "en",
)).to.equal("Last business day 2d");
});
it("(#83) positive offset on nth_weekday", () => {
expect(formatRecurrence(
{ schedule: { kind: "nth_weekday", nth: 1, weekday: 5, offset: 2 } }, "en",
)).to.equal("1st Saturday +2d");
});
it("manual → 'Manual'", () => {
expect(formatRecurrence({ schedule: { kind: "manual" } }, "en")).to.equal("Manual");
});
it("one_time → due date", () => {
expect(formatRecurrence({ schedule: { kind: "one_time" }, due_date: "2026-09-01" }, "en")).to.contain("2026");
});
it("legacy flat interval (no nested schedule) → '30 Days'", () => {
expect(formatRecurrence({ interval_days: 30, interval_unit: "days", schedule_type: "time_based" }, "en")).to.equal("30 Days");
});
it("German nth_weekday → '1. Samstag'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "de")).to.equal("1. Samstag");
});
it("empty weekdays → em dash", () => {
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [] } }, "en")).to.equal("—");
});
it("sensor_based (no schedule) → 'Sensor-based', not em dash", () => {
expect(
formatRecurrence({ schedule_type: "sensor_based", interval_days: null }, "en"),
).to.equal("Sensor-based");
});
});
@@ -0,0 +1,109 @@
/**
* Component tests for the group-dialog task list (#40 sort fix, v1.0.53).
*
* Pins:
* - Object sections render alphabetically (not in `.objects` array order)
* - Tasks within each object render alphabetically
* - Toggling a checkbox updates the internal Set with the right
* "entry_id:task_id" composite key
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/group-dialog.js";
import type { MaintenanceGroupDialog } from "../components/group-dialog";
function mockObjects() {
// Deliberately given in non-alphabetical creation order.
return [
{
entry_id: "e3",
object: { id: "o3", name: "Zenith Compressor" },
tasks: [
{ id: "t31", name: "Tighten bolts" },
{ id: "t32", name: "Air filter swap" },
],
},
{
entry_id: "e1",
object: { id: "o1", name: "Aqua Pool" },
tasks: [
{ id: "t11", name: "pH check" },
{ id: "t12", name: "Brush walls" },
{ id: "t13", name: "Add chlorine" },
],
},
{
entry_id: "e2",
object: { id: "o2", name: "Mid Garage" },
tasks: [
{ id: "t21", name: "Sweep floor" },
],
},
];
}
async function mount() {
const hass = { language: "en", connection: { sendMessagePromise: async () => ({}) } };
const el = await fixture<MaintenanceGroupDialog>(html`
<maintenance-group-dialog .hass=${hass} .objects=${mockObjects()}></maintenance-group-dialog>
`);
el.openCreate();
await el.updateComplete;
return el;
}
describe("group-dialog task list (#40 alphabetical sort)", () => {
it("renders object sections alphabetically by object name", async () => {
const el = await mount();
const sections = el.shadowRoot!.querySelectorAll(".object-block .object-name");
const names = [...sections].map(n => n.textContent?.trim() || "");
expect(names, "object sections sorted A→Z").to.deep.equal([
"Aqua Pool",
"Mid Garage",
"Zenith Compressor",
]);
});
it("renders tasks within each object alphabetically", async () => {
const el = await mount();
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
// Aqua Pool block (first after sort) should have tasks A→Z
const aquaTasks = blocks[0].querySelectorAll(".task-row span");
const taskNames = [...aquaTasks].map(s => s.textContent?.trim() || "");
expect(taskNames, "Aqua Pool tasks A→Z").to.deep.equal([
"Add chlorine",
"Brush walls",
"pH check",
]);
});
it("toggles a checkbox and stores the composite entry:task key", async () => {
const el = await mount();
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
const firstCheckbox = blocks[0].querySelector<HTMLInputElement>(".task-row input[type=checkbox]")!;
firstCheckbox.checked = true;
firstCheckbox.dispatchEvent(new Event("change"));
await el.updateComplete;
// First sorted object is Aqua Pool (e1); first sorted task is "Add chlorine" (t13)
// Internal _selected uses entry_id:task_id. Inspect via the rendered count.
const countEl = el.shadowRoot!.querySelector(".selected-count");
expect(countEl?.textContent || "").to.match(/1\b/);
});
it("checkbox state survives object re-render via property update", async () => {
const el = await mount();
const blocks1 = el.shadowRoot!.querySelectorAll(".object-block");
const cb = blocks1[0].querySelector<HTMLInputElement>(".task-row input")!;
cb.checked = true;
cb.dispatchEvent(new Event("change"));
await el.updateComplete;
// Force a re-render with the same objects (ordering invariant).
el.objects = [...mockObjects()];
await el.updateComplete;
const cbAgain = el.shadowRoot!.querySelectorAll(".object-block")[0]
.querySelector<HTMLInputElement>(".task-row input")!;
expect(cbAgain.checked, "checked state preserved across re-render").to.be.true;
});
});
@@ -0,0 +1,63 @@
/**
* i18n runtime-loader guard.
*
* The panel/card UI strings used to be one big inline `TRANSLATIONS` object in
* styles.ts. They now live in `frontend-src/locales/<lang>.json`: only English
* is bundled (imported by styles.ts) as the always-available fallback; the other
* 12 languages are fetched at runtime from `/maintenance_supporter_locales/`
* so a translation edit needs no bundle rebuild (the stale-bundle pitfall fix).
*
* Cross-locale KEY PARITY is guarded in Python (`tests/test_i18n.py` reads every
* `frontend-src/locales/*.json`). This browser test guards the runtime LOADER
* behaviour that replaced the inline tables: bundled-EN, English fallback, and
* the no-fetch fast paths of ensureLocale/isLocaleLoaded.
*/
import { expect } from "@open-wc/testing";
import { t, isLocaleLoaded, ensureLocale } from "../styles";
describe("i18n runtime loader", () => {
it("serves bundled English synchronously", () => {
// A key known to exist in en.json resolves with no fetch and isn't the key.
expect(t("loading", "en")).to.be.a("string").and.not.equal("loading");
});
it("falls back to English for an unloaded language, then to the key", () => {
// German isn't bundled; before load an EN-present key falls back to EN…
expect(t("loading", "de")).to.equal(t("loading", "en"));
// …and an unknown key returns the key itself.
expect(t("__nonexistent_key__", "de")).to.equal("__nonexistent_key__");
});
it("treats English (and its regional variants) as always loaded", () => {
expect(isLocaleLoaded("en")).to.be.true;
expect(isLocaleLoaded("en-GB")).to.be.true; // normalises to "en"
expect(isLocaleLoaded("de")).to.be.false; // not fetched yet
});
it("ensureLocale resolves immediately for English and unsupported langs", async () => {
await ensureLocale("en"); // bundled → no-op
await ensureLocale("xx"); // unsupported → no fetch, stays on English
expect(isLocaleLoaded("xx")).to.be.false;
});
it("shares the locale store across bundle copies via window (German-panel/English-dialog regression)", () => {
// maintenance-card.js is loaded on EVERY page (extra_module_url) and its
// custom-element definitions win first — so a dialog inside the panel
// executes the CARD bundle's copy of styles.ts. If each copy had its own
// module-scoped store, the panel's locale load would never reach the
// dialog: German panel, English "Edit Task" dialog (the v2.17.0 bug).
// Guard: the store is window-scoped, so writing to the global (as another
// bundle copy would) is immediately visible to this copy's t().
const g = (window as unknown as {
__msLocales?: { store: Record<string, Record<string, string>> };
}).__msLocales;
expect(g, "window.__msLocales must back the locale store").to.exist;
g!.store.pt = { loading: "A carregar (from another bundle copy)" };
try {
expect(t("loading", "pt")).to.equal("A carregar (from another bundle copy)");
expect(isLocaleLoaded("pt")).to.be.true;
} finally {
delete g!.store.pt;
}
});
});
@@ -0,0 +1,47 @@
/**
* Unit-aware interval math (issue #59) progress bars + calendar projection
* must treat weeks/months/years as their real day-span, not the raw count.
*/
import { expect } from "@open-wc/testing";
import { intervalSpanDays, daysProgress } from "../helpers/interval";
describe("intervalSpanDays (#59)", () => {
it("days = raw count", () => expect(intervalSpanDays(7, "days")).to.equal(7));
it("weeks × 7", () => expect(intervalSpanDays(2, "weeks")).to.equal(14));
it("months ≈ 30.44/mo", () =>
expect(intervalSpanDays(3, "months")).to.be.closeTo(91.3, 0.5));
it("years ≈ 365.25", () =>
expect(intervalSpanDays(1, "years")).to.be.closeTo(365.25, 0.01));
it("missing unit defaults to days", () =>
expect(intervalSpanDays(5)).to.equal(5));
it("zero / null → 0", () => {
expect(intervalSpanDays(0, "years")).to.equal(0);
expect(intervalSpanDays(null, "years")).to.equal(0);
});
});
describe("daysProgress (#59)", () => {
it("yearly task halfway → ~50% (pre-fix clamped to 0)", () => {
const p = daysProgress(1, 182, "years");
expect(p.pct).to.be.closeTo(50, 1);
expect(p.overflow).to.equal(false);
});
it("yearly task overdue → 100% + overflow", () => {
const p = daysProgress(1, -10, "years");
expect(p.pct).to.equal(100);
expect(p.overflow).to.equal(true);
});
it("days unit unchanged: just performed → 0%", () =>
expect(daysProgress(30, 30, "days").pct).to.equal(0));
it("days unit: due today → 100%", () =>
expect(daysProgress(30, 0, "days").pct).to.equal(100));
it("monthly task: 1 of ~30 days elapsed → small %", () => {
const p = daysProgress(1, 29, "months");
expect(p.pct).to.be.greaterThan(0);
expect(p.pct).to.be.lessThan(15);
});
it("no interval / null countdown → 0", () => {
expect(daysProgress(null, 5, "days").pct).to.equal(0);
expect(daysProgress(30, null, "days").pct).to.equal(0);
});
});
@@ -0,0 +1,85 @@
/**
* Tripwire: the strategy's document-level `ll-custom` handler must accept BOTH
* payload shapes that reach it in production.
*
* 1. HA's `tap_action: { action: "fire-dom-event", ll_custom: {...} }` path
* (the empty-state "Add object" button) dispatches `ll-custom` with the
* WHOLE action config as the event detail our payload nests under
* `.ll_custom`. This is the shape that broke in issue #69: the handler
* read `detail.type` (undefined) and silently did nothing.
*
* 2. Direct dispatchers the calendar card and the panel put the payload
* at the TOP level of the detail (`{ type, entry_id, task_id }`).
*
* Both must open the create-object dialog. The pre-#69 verifier only exercised
* shape 2, which is why the regression shipped. If you change how the handler
* reads the payload, keep both green.
*/
import { expect } from "@open-wc/testing";
import { createMockHass } from "./_test-utils.js";
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
// A fake <home-assistant> with a mock hass so dialog-mount's getHass() succeeds
// and openCreateObjectDialog() returns true (no deep-link fallback / no URL
// mutation of the test page).
let haRoot: HTMLElement & { hass?: unknown };
before(async () => {
const { hass } = createMockHass();
haRoot = document.createElement("home-assistant") as HTMLElement & { hass?: unknown };
haRoot.hass = hass;
document.body.appendChild(haRoot);
// Import for its side effect: registers the document-level ll-custom handler.
await import("../maintenance-dashboard-strategy.js");
});
after(() => {
haRoot?.remove();
});
afterEach(() => {
document.body.querySelector(OBJECT_DIALOG_TAG)?.remove();
});
async function fireAndWait(detail: unknown): Promise<HTMLElement | null> {
document.dispatchEvent(
new CustomEvent("ll-custom", { detail, bubbles: true, composed: true }),
);
// handler imports dialog-mount.ts dynamically, then mounts the dialog
for (let i = 0; i < 40; i++) {
const dlg = document.body.querySelector<HTMLElement>(OBJECT_DIALOG_TAG);
if (dlg) return dlg;
await tick(25);
}
return null;
}
describe("ll-custom handler payload shape (#69)", () => {
it("opens the object dialog for HA's nested fire-dom-event shape", async () => {
const dlg = await fireAndWait({
action: "fire-dom-event",
ll_custom: { type: "maintenance-supporter:add-object" },
});
expect(dlg, "nested ll_custom payload must reach the add-object handler").to.not.equal(null);
});
it("opens the object dialog for the top-level (calendar/panel) shape", async () => {
const dlg = await fireAndWait({ type: "maintenance-supporter:add-object" });
expect(dlg, "top-level payload must still work (calendar card / panel)").to.not.equal(null);
});
it("ignores ll-custom events for other namespaces", async () => {
document.dispatchEvent(
new CustomEvent("ll-custom", {
detail: { action: "fire-dom-event", ll_custom: { type: "browser_mod:foo" } },
bubbles: true,
composed: true,
}),
);
await tick(150);
expect(document.body.querySelector(OBJECT_DIALOG_TAG)).to.equal(null);
});
});
@@ -0,0 +1,54 @@
/**
* Objects-table column catalog + sanitiser (#67). Mirrors the backend
* sanitise so the panel and Settings UI agree on what's valid.
*/
import { expect } from "@open-wc/testing";
import {
sanitizeColumns,
DEFAULT_OBJECTS_TABLE_COLUMNS,
KNOWN_OBJECT_COLUMNS,
OBJECT_COLUMNS,
} from "../helpers/object-columns";
describe("sanitizeColumns (#67)", () => {
it("non-array → defaults", () => {
expect(sanitizeColumns(undefined)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns(null)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns("name")).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
});
it("empty / all-unknown → defaults", () => {
expect(sanitizeColumns([])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns(["nope", 7, null])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
});
it("drops unknown keys, preserves caller order", () => {
expect(sanitizeColumns(["name", "bogus", "warranty_expiry"]))
.to.deep.equal(["name", "warranty_expiry"]);
});
it("dedupes", () => {
expect(sanitizeColumns(["name", "name", "model"]))
.to.deep.equal(["name", "model"]);
});
it("prepends the required name column when missing", () => {
expect(sanitizeColumns(["warranty_expiry", "model"]))
.to.deep.equal(["name", "warranty_expiry", "model"]);
});
it("accepts a full custom subset unchanged", () => {
const cols = ["name", "warranty_expiry", "actions"];
expect(sanitizeColumns(cols)).to.deep.equal(cols);
});
it("catalog is internally consistent", () => {
expect(KNOWN_OBJECT_COLUMNS.length).to.equal(OBJECT_COLUMNS.length);
for (const c of OBJECT_COLUMNS) {
expect(c.labelKey, c.key).to.be.a("string").and.not.equal("");
}
for (const k of DEFAULT_OBJECTS_TABLE_COLUMNS) {
expect(KNOWN_OBJECT_COLUMNS, k).to.include(k);
}
});
});
@@ -0,0 +1,111 @@
/**
* QR deep-link routing tests (audit gap #9).
*
* A scanned QR code lands on the panel URL with ?entry_id&task_id&action=
* until now only the URL *building* was tested, never the handling. These
* tests pin the scan-to-complete story at the routing layer:
* - action=complete opens the pre-targeted complete dialog on the task view
* - action=quick_complete fires task/quick_complete silently
* - a no_defaults refusal falls back to the normal complete dialog
* - params are consumed once (cleaned from the URL)
* - an unknown entry_id lands safely on the overview
*/
import { expect } from "@open-wc/testing";
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
function setDeepLink(query: string) {
history.replaceState(null, "", `${window.location.pathname}?${query}`);
}
async function settleRaf(el: { updateComplete: Promise<unknown> }) {
// Deep-link dialog opens behind a requestAnimationFrame.
await new Promise((r) => requestAnimationFrame(() => r(null)));
await new Promise((r) => setTimeout(r, 20));
await (el as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
}
function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null {
return sr(el).querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
}
describe("panel deep links (QR scan routing)", () => {
beforeEach(() => {
resetTaskSeq();
localStorage.clear();
localStorage.setItem("msp-overview-tab", "dashboard");
});
afterEach(() => {
localStorage.clear();
history.replaceState(null, "", window.location.pathname);
});
it("?action=complete lands on the task and opens the pre-targeted dialog", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=complete");
const { el } = await mountPanel([
obj("e1", [task({ name: "Scan Me" })]),
]);
await settleRaf(el);
// Landed on the task detail…
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent).to.include("Scan Me");
// …with the complete dialog open and targeted at the scanned task.
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "complete dialog open").to.exist;
expect(dlg.entryId).to.equal("e1");
expect(dlg.taskId).to.equal("t1");
expect(dlg.taskName).to.equal("Scan Me");
// Params are consumed once: the URL is cleaned.
expect(window.location.search).to.equal("");
});
it("?action=quick_complete fires task/quick_complete silently", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "Quick" })])],
{ "maintenance_supporter/task/quick_complete": () => ({ success: true, via: "quick" }) },
);
await settleRaf(el);
const quick = sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete");
expect(quick.length).to.equal(1);
expect(quick[0].entry_id).to.equal("e1");
expect(quick[0].task_id).to.equal("t1");
// Silent path: no dialog opened.
const dlg = completeDialog(el);
expect(dlg?.shadowRoot?.querySelector("ha-dialog") ?? null).to.be.null;
});
it("quick_complete without defaults falls back to the complete dialog", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "No Defaults" })])],
{
"maintenance_supporter/task/quick_complete": () => {
throw { code: "no_defaults", message: "open the dialog instead" };
},
},
);
await settleRaf(el);
await settleRaf(el); // fallback opens after the rejected promise settles
expect(
sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete").length,
).to.equal(1);
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
expect(dlg.taskName).to.equal("No Defaults");
});
it("an unknown entry_id lands safely on the overview", async () => {
setDeepLink("entry_id=does-not-exist&task_id=t1&action=complete");
const { el } = await mountPanel([obj("e1", [task({ name: "Real" })])]);
await settleRaf(el);
expect(sr(el).querySelector(".task-header")).to.be.null;
expect(sr(el).querySelector(".task-table"), "overview dashboard rendered").to.exist;
expect(window.location.search).to.equal("");
});
});
@@ -0,0 +1,188 @@
import { expect } from "@open-wc/testing";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
describe("panel shell", () => {
beforeEach(() => {
resetTaskSeq();
localStorage.clear();
localStorage.setItem("msp-overview-tab", "dashboard");
});
afterEach(() => localStorage.clear());
it("bulk select-all covers visible rows only and bulk Complete sends one call per selection", async () => {
const { el, sent } = await mountPanel([
obj("e1", [
task({ name: "Active A" }),
task({ name: "Active B" }),
task({ name: "Active C" }),
task({ name: "Gone", archived: true }),
]),
]);
// Enter bulk mode.
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
await el.updateComplete;
expect(sr(el).querySelector(".bulk-bar"), "bulk bar visible").to.exist;
// Select all → exactly the 3 visible (non-archived) rows get checkboxes.
sr(el).querySelector<HTMLInputElement>(".bulk-selectall input")!.click();
await el.updateComplete;
const checked = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")]
.filter((c) => c.checked);
expect(checked.length).to.equal(3);
// Bulk Complete → one task/complete per selected task, none for archived.
const completeBtn = sr(el).querySelector<HTMLElement>(".bulk-actions ha-button")!;
completeBtn.click();
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const completes = sent.filter((m) => m.type === "maintenance_supporter/task/complete");
expect(completes.length).to.equal(3);
expect(new Set(completes.map((m) => m.task_id))).to.deep.equal(
new Set(["t1", "t2", "t3"]),
);
// Bulk mode exits after the action.
expect(sr(el).querySelector(".bulk-bar")).to.be.null;
});
it("bulk Archive sends task/archive for the manually selected rows only", async () => {
const { el, sent } = await mountPanel([
obj("e1", [task({ name: "One" }), task({ name: "Two" }), task({ name: "Three" })]),
]);
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
await el.updateComplete;
// Tick rows 1 and 3 via their row checkboxes.
const boxes = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")];
boxes[0].click();
boxes[2].click();
await el.updateComplete;
// Second bulk action button is Archive.
const actions = [...sr(el).querySelectorAll<HTMLElement>(".bulk-actions ha-button")];
actions[1].click();
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const archives = sent.filter((m) => m.type === "maintenance_supporter/task/archive");
expect(archives.length).to.equal(2);
expect(new Set(archives.map((m) => m.task_id))).to.deep.equal(new Set(["t1", "t3"]));
expect(sent.filter((m) => m.type === "maintenance_supporter/task/complete").length)
.to.equal(0);
});
it("'/' opens the command palette, filters, and navigates to the task", async () => {
const { el } = await mountPanel([
obj("e1", [task({ name: "Filter Wechsel" }), task({ name: "Pumpe prüfen" })]),
]);
// Ctrl+K must NOT open it — that's HA's own global-search hotkey and the
// panel used to shadow it (changed in 2.18.1).
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
await el.updateComplete;
expect(sr(el).querySelector(".palette-input"), "Ctrl+K stays HA's").to.be.null;
window.dispatchEvent(new KeyboardEvent("keydown", { key: "/" }));
await el.updateComplete;
const input = sr(el).querySelector<HTMLInputElement>(".palette-input");
expect(input, "palette opened").to.exist;
input!.value = "Filter";
input!.dispatchEvent(new Event("input"));
await el.updateComplete;
const results = [...sr(el).querySelectorAll(".palette-results .palette-label")]
.map((r) => r.textContent?.trim());
expect(results).to.include("Filter Wechsel");
expect(results).to.not.include("Pumpe prüfen");
// Click the task result → task detail renders.
const hit = [...sr(el).querySelectorAll<HTMLElement>(".palette-results > *")]
.find((r) => /Filter Wechsel/.test(r.textContent || ""))!;
hit.click();
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
expect(sr(el).querySelector(".palette-input"), "palette closed").to.be.null;
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent)
.to.include("Filter Wechsel");
});
it("'/' while typing in a text field does not open the palette", async () => {
const { el } = await mountPanel([
obj("e1", [task({ name: "Filter Wechsel" })]),
]);
const field = document.createElement("input");
document.body.appendChild(field);
try {
field.dispatchEvent(
new KeyboardEvent("keydown", { key: "/", bubbles: true, composed: true }),
);
await el.updateComplete;
expect(sr(el).querySelector(".palette-input")).to.be.null;
} finally {
field.remove();
}
});
it("Today view buckets overdue / due-today / this-week and hides later tasks", async () => {
localStorage.setItem("msp-overview-tab", "today");
const { el } = await mountPanel([
obj("e1", [
task({ name: "Late", status: "overdue", days_until_due: -3 }),
task({ name: "Now", status: "due_soon", days_until_due: 0 }),
task({ name: "Soon", status: "due_soon", days_until_due: 3 }),
task({ name: "Later", status: "ok", days_until_due: 20 }),
]),
]);
const view = sr(el).querySelector(".today-view");
expect(view, "today view rendered").to.exist;
const sections = [...sr(el).querySelectorAll(".today-section")];
const byHeader = (re: RegExp) =>
sections.find((s) => re.test(s.querySelector(".today-section-header")!.textContent || ""));
const textOf = (s: Element | undefined) =>
[...(s?.querySelectorAll(".today-task") || [])].map((t2) => t2.textContent?.trim());
const all = sections.flatMap((s) => textOf(s));
expect(all).to.include("Late");
expect(all).to.include("Now");
expect(all).to.include("Soon");
expect(all).to.not.include("Later");
// Overdue section leads with the late task.
const overdueSection = byHeader(/overdue|überfällig/i);
expect(textOf(overdueSection)).to.include("Late");
});
it("virtualizes the table above the threshold and moves the window on scroll", async () => {
const many = Array.from({ length: 150 }, (_, i) =>
task({ name: `Bulk ${String(i).padStart(3, "0")}`, days_until_due: (i % 40) + 1 }),
);
const { el } = await mountPanel([obj("e1", many)]);
const table = sr(el).querySelector(".task-table");
expect(table, "table rendered").to.exist;
expect(table!.classList.contains("virtual"), "virtual mode active").to.be.true;
const domRows = () =>
[...sr(el).querySelectorAll(".task-table .task-row:not(.virt-sizer)")];
expect(domRows().length).to.be.lessThan(120);
expect(domRows().length).to.be.greaterThan(5);
// Scroll the content container → the window shifts and a top spacer grows.
const content = sr(el).querySelector<HTMLElement>(".content")!;
content.scrollTop = 3000;
content.dispatchEvent(new Event("scroll"));
await new Promise((r) => requestAnimationFrame(() => r(null)));
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const spacer = sr(el).querySelector<HTMLElement>(".task-table .virt-spacer");
expect(spacer, "top spacer present after scroll").to.exist;
expect(parseInt(spacer!.style.height, 10)).to.be.greaterThan(0);
const firstName = domRows()[0]?.querySelector(".task-name")?.textContent?.trim();
expect(firstName).to.not.equal("Bulk 000");
});
});
@@ -0,0 +1,97 @@
/**
* Lit component tests for the notify-target picker (datalist) in the general
* section of <maintenance-settings-view>.
*
* The pickable-target LIST is computed server-side by build_notify_targets and
* arrives as `settings.general.notify_targets` (merging legacy notify services +
* notify entities, minus the generic send_message, plus the saved value). The
* panel's only job is to render that list into a <datalist> while keeping the
* input free-text. The merge logic itself is covered by the Python tests for
* build_notify_targets here we only assert the panel mirrors what it's given.
* See settings-view.ts::_renderGeneral.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import {
DEFAULT_FEATURES,
DEFAULT_SETTINGS_RESPONSE,
createMockHass,
} from "./_test-utils.js";
async function mount(notifyTargets?: string[]): Promise<MaintenanceSettingsView> {
const { hass } = createMockHass({
settingsResponse: {
...DEFAULT_SETTINGS_RESPONSE,
general: {
...DEFAULT_SETTINGS_RESPONSE.general,
notifications_enabled: true,
notify_service: "notify.mobile_app_phone",
...(notifyTargets !== undefined ? { notify_targets: notifyTargets } : {}),
},
},
});
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
// _loadSettings is kicked off by updated() after first render.
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
return el;
}
function options(el: MaintenanceSettingsView): string[] {
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
return Array.from(list?.querySelectorAll("option") ?? []).map((o) => o.value);
}
describe("settings-view notify-target picker", () => {
it("renders the server-provided notify_targets verbatim as datalist options", async () => {
const el = await mount([
"notify.mobile_app_phone",
"notify.all_devices_group",
"notify.file",
]);
const opts = options(el);
expect(opts).to.include("notify.mobile_app_phone");
expect(opts).to.include("notify.all_devices_group");
expect(opts).to.include("notify.file");
expect(opts).to.have.lengthOf(3);
});
it("degrades to an empty datalist when no notify targets are provided", async () => {
const el = await mount([]);
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
expect(list, "datalist still present").to.exist;
expect(list!.querySelectorAll("option").length, "no options").to.equal(0);
// Free-text input still renders so notifications can be configured.
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
'input[list="ms-notify-services"]',
);
expect(input, "free-text input still present").to.exist;
});
it("degrades gracefully when an older backend omits notify_targets", async () => {
// notify_targets absent entirely (undefined) → no options, no crash.
const { hass } = createMockHass({
settingsResponse: {
...DEFAULT_SETTINGS_RESPONSE,
general: {
default_warning_days: 7,
notifications_enabled: true,
notify_service: "notify.mobile_app_phone",
panel_enabled: false,
// notify_targets deliberately omitted
} as unknown as (typeof DEFAULT_SETTINGS_RESPONSE)["general"],
},
});
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
expect(options(el), "no options when omitted").to.have.lengthOf(0);
});
});
@@ -0,0 +1,182 @@
/**
* Lit component tests for the Print QR section of <maintenance-settings-view>.
*
* Covers v1.1.0 batch-QR-print UI: load-objects flow, action chip toggles,
* generate button enable/disable, results rendering.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import { DEFAULT_FEATURES, createMockHass } from "./_test-utils.js";
function mockHass(opts: {
objects?: Array<{ entry_id: string; name: string; task_count: number }>;
batchResult?: Array<{ task_id: string; entry_id: string; object_name: string; task_name: string; action: string; svg: string }>;
} = {}) {
const objects = opts.objects ?? [
{ entry_id: "e1", name: "Pool Pump", task_count: 3 },
{ entry_id: "e2", name: "HVAC", task_count: 2 },
];
return createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({
objects: objects.map(o => ({
entry_id: o.entry_id,
object: { name: o.name },
tasks: Array.from({ length: o.task_count }, (_, i) => ({
id: `${o.entry_id}_t${i}`, name: `Task ${i}`,
})),
})),
}),
"maintenance_supporter/qr/batch_generate": () => ({
qrs: opts.batchResult ?? [
{
entry_id: "e1", task_id: "e1_t0",
object_name: "Pool Pump", task_name: "Task 0",
action: "view", svg: "<svg><rect/></svg>",
},
],
total: 1,
}),
},
});
}
async function mount(opts = {}) {
const { hass, sent } = mockHass(opts);
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
await new Promise(r => setTimeout(r, 50));
await el.updateComplete;
return { el, sent };
}
function qrSection(el: MaintenanceSettingsView): HTMLElement | null {
return el.shadowRoot?.querySelector(".qr-print-section") || null;
}
describe("settings-view print QR section", () => {
it("renders the section with a Load objects button by default", async () => {
const { el } = await mount();
const section = qrSection(el);
expect(section, "section exists").to.exist;
const buttons = section!.querySelectorAll("button");
const loadBtn = [...buttons].find(b => /load/i.test(b.textContent || ""));
expect(loadBtn, "Load objects button present").to.exist;
});
it("loads objects on click and renders the filter panel", async () => {
const { el, sent } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const objCalled = sent.some(m => m.type === "maintenance_supporter/objects");
expect(objCalled, "objects WS called").to.be.true;
const filterPanel = qrSection(el)!.querySelector(".qr-filter-panel");
expect(filterPanel, "filter panel rendered after load").to.exist;
const objectRows = qrSection(el)!.querySelectorAll(".qr-object-row");
expect(objectRows.length, "object rows match mock count").to.equal(2);
});
it("toggles an action chip and updates active class", async () => {
const { el } = await mount();
// Trigger load to render the chips
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const chips = qrSection(el)!.querySelectorAll<HTMLElement>(".qr-action-chip");
expect(chips.length, "three action chips rendered").to.equal(3);
// Default: only "view" active
const viewChip = [...chips].find(c => /view|anzeigen/i.test(c.textContent || ""))!;
expect(viewChip.classList.contains("active"), "view chip active by default").to.be.true;
// Toggle "complete" on
const completeChip = [...chips].find(c => /complete|erledigen/i.test(c.textContent || ""))!;
const completeInput = completeChip.querySelector<HTMLInputElement>("input")!;
completeInput.checked = true;
completeInput.dispatchEvent(new Event("change"));
await el.updateComplete;
expect(completeChip.classList.contains("active"), "complete chip flipped active").to.be.true;
});
it("disables Generate button when no actions selected", async () => {
const { el } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
// Toggle the default "view" off
const viewChipInput = [...qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input")]
.find(i => i.checked)!;
viewChipInput.checked = false;
viewChipInput.dispatchEvent(new Event("change"));
await el.updateComplete;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
expect(genBtn.disabled, "Generate disabled when 0 actions").to.be.true;
});
it("renders a batch result row after Generate", async () => {
const { el, sent } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
genBtn.click();
await new Promise(r => setTimeout(r, 50));
await el.updateComplete;
const batchCalled = sent.some(m => m.type === "maintenance_supporter/qr/batch_generate");
expect(batchCalled, "batch_generate WS called").to.be.true;
const cells = qrSection(el)!.querySelectorAll(".qr-print-cell");
expect(cells.length, "one result cell rendered").to.equal(1);
const labelObj = cells[0].querySelector(".qr-label-obj");
expect(labelObj?.textContent).to.equal("Pool Pump");
});
it("warns over-limit when filter would produce > 200 QRs", async () => {
// 100 objects with 3 actions = 300 QRs → over the 200 cap.
const manyObjects = Array.from({ length: 101 }, (_, i) => ({
entry_id: `e${i}`, name: `Obj ${i}`, task_count: 1,
}));
const { el } = await mount({ objects: manyObjects });
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
// Toggle on all 3 actions
const chips = qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input");
for (const chip of chips) {
if (!chip.checked) {
chip.checked = true;
chip.dispatchEvent(new Event("change"));
}
}
await el.updateComplete;
const estimate = qrSection(el)!.querySelector(".qr-estimate");
expect(estimate?.classList.contains("error"), "estimate shows error class").to.be.true;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
expect(genBtn.disabled, "Generate disabled at over-limit").to.be.true;
});
});
@@ -0,0 +1,147 @@
/**
* Lit component tests for the vacation section of <maintenance-settings-view>.
*
* Mounts the component with a mocked `hass` (just a connection stub that
* captures sendMessagePromise calls) and asserts on rendered output +
* outgoing WS messages. No HA shell, no shadow-DOM-deep-piercing
* runs in real Chromium via @web/test-runner.
*/
import { expect, fixture, html, oneEvent } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import {
DEFAULT_FEATURES,
DEFAULT_SETTINGS_RESPONSE,
createMockHass,
} from "./_test-utils.js";
function mockHass(opts: {
vacationActive?: boolean;
vacationStart?: string | null;
vacationEnd?: string | null;
exemptIds?: string[];
} = {}) {
const settingsResponse = {
...DEFAULT_SETTINGS_RESPONSE,
vacation: {
...DEFAULT_SETTINGS_RESPONSE.vacation,
enabled: opts.vacationActive ?? false,
start: opts.vacationStart ?? null,
end: opts.vacationEnd ?? null,
exempt_task_ids: opts.exemptIds ?? [],
is_active: opts.vacationActive ?? false,
window_end: opts.vacationEnd ?? null,
},
};
return createMockHass({
settingsResponse,
handlers: {
"maintenance_supporter/vacation/update": (msg) => ({
// Echo the patch merged onto current vacation state — what the
// real backend does.
...settingsResponse.vacation,
...(msg.enabled !== undefined ? { enabled: msg.enabled, is_active: msg.enabled } : {}),
...(msg.start !== undefined ? { start: msg.start } : {}),
...(msg.end !== undefined ? { end: msg.end } : {}),
...(msg.buffer_days !== undefined ? { buffer_days: msg.buffer_days } : {}),
...(msg.exempt_task_ids !== undefined ? { exempt_task_ids: msg.exempt_task_ids } : {}),
}),
"maintenance_supporter/vacation/end_now": () => ({
...settingsResponse.vacation, enabled: false, is_active: false,
}),
"maintenance_supporter/vacation/preview": () => ({ rows: [], window_end: null }),
},
});
}
async function mount(opts = {}) {
const { hass, sent } = mockHass(opts);
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
// Wait for _loadSettings to complete — it's kicked off by updated()
// which fires after first render.
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
return { el, sent };
}
function vacationSection(el: MaintenanceSettingsView): HTMLElement | null {
return el.shadowRoot?.querySelector(".vacation-section") || null;
}
describe("settings-view vacation section", () => {
it("renders the vacation section with title and disabled toggle by default", async () => {
const { el } = await mount();
const section = vacationSection(el);
expect(section, "vacation section present").to.exist;
const h3 = section!.querySelector("h3");
expect(h3?.textContent || "").to.match(/vacation|urlaub/i);
const toggle = section!.querySelector<HTMLInputElement>(".vac-toggle input");
expect(toggle, "enable toggle present").to.exist;
expect(toggle!.checked, "toggle off by default").to.be.false;
});
it("hydrates dates from settings response", async () => {
const { el } = await mount({
vacationStart: "2099-06-10",
vacationEnd: "2099-06-20",
});
const dateInputs = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=date]");
expect(dateInputs.length, "two date inputs").to.equal(2);
expect(dateInputs[0].value).to.equal("2099-06-10");
expect(dateInputs[1].value).to.equal("2099-06-20");
});
it("shows the active badge when vacation.is_active is true", async () => {
const { el } = await mount({
vacationActive: true,
vacationStart: "2099-06-10",
vacationEnd: "2099-06-20",
});
const badge = vacationSection(el)!.querySelector(".vac-badge.active");
expect(badge, "active badge rendered").to.exist;
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
expect(endNow, "end-now button rendered").to.exist;
});
it("dispatches vacation/update when the enable toggle is clicked", async () => {
const { el, sent } = await mount();
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 30));
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update");
expect(update, "update message sent").to.exist;
expect(update!.enabled, "enabled=true in payload").to.equal(true);
});
it("dispatches vacation/update with new buffer_days when number changes", async () => {
const { el, sent } = await mount();
const buffer = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=number]")[0];
expect(buffer, "buffer input present").to.exist;
buffer.value = "7";
buffer.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 30));
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update" && m.buffer_days === 7);
expect(update, "update with buffer_days=7 sent").to.exist;
});
it("emits settings-changed when vacation toggles (so the panel re-evaluates the Vacation tab)", async () => {
const { el } = await mount();
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
toggle.checked = true;
const evtPromise = oneEvent(el, "settings-changed");
toggle.dispatchEvent(new Event("change"));
const evt = await evtPromise;
expect(evt.type).to.equal("settings-changed");
});
it("does not show end-now button when vacation is disabled and not stale", async () => {
const { el } = await mount();
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
expect(endNow, "no end-now in default state").to.not.exist;
});
});
@@ -0,0 +1,117 @@
/** Lit component tests for <maintenance-storage-section-card>. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/storage-section-card.js";
import type { MaintenanceStorageSectionCard } from "../components/storage-section-card";
import { createMockHass } from "./_test-utils.js";
const SUMMARY = {
total_bytes: 13, dedup_savings_bytes: 10, file_count: 3, link_count: 1, document_count: 4,
by_object: {
objA: { bytes: 10, files: 2, links: 0 },
objB: { bytes: 3, files: 1, links: 1 },
},
};
const OBJECTS = [
{ entry_id: "e1", object: { id: "objA", name: "Pool Pump" } },
{ entry_id: "e2", object: { id: "objB", name: "HVAC" } },
];
async function mount(summary: unknown = SUMMARY, objects: unknown = OBJECTS) {
const { hass } = createMockHass({
handlers: { "maintenance_supporter/documents/storage": () => summary },
});
const el = await fixture<MaintenanceStorageSectionCard>(html`
<maintenance-storage-section-card .hass=${hass} .objects=${objects}></maintenance-storage-section-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return el;
}
async function expand(el: MaintenanceStorageSectionCard) {
el.shadowRoot!.querySelector<HTMLButtonElement>(".toggle")!.click();
await el.updateComplete;
}
describe("storage-section-card", () => {
it("is collapsed by default and expands on toggle", async () => {
const el = await mount();
expect(el.shadowRoot!.querySelector(".body"), "collapsed by default").to.not.exist;
// the headline total stays visible in the collapsed header
expect(el.shadowRoot!.querySelector(".header-summary"), "summary in header").to.exist;
await expand(el);
expect(el.shadowRoot!.querySelector(".body"), "expands on toggle").to.exist;
expect(el.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(2);
});
it("renders total, dedup saving and per-object rows sorted by size", async () => {
const el = await mount();
await expand(el);
const rows = el.shadowRoot!.querySelectorAll(".obj-row");
expect(rows.length).to.equal(2);
expect(rows[0].querySelector(".obj-name")!.textContent).to.contain("Pool Pump"); // 10 B first
expect(rows[1].querySelector(".obj-name")!.textContent).to.contain("HVAC");
expect(el.shadowRoot!.querySelector(".stat-value.saved"), "dedup saving shown").to.exist;
});
it("self-hides when there are no documents", async () => {
const el = await mount({ ...SUMMARY, document_count: 0, by_object: {} });
expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist;
});
it("navigates to an object when its row is clicked", async () => {
const el = await mount();
await expand(el);
let openedEntry = "";
el.addEventListener("open-object", (e) => { openedEntry = (e as CustomEvent).detail.entry_id; });
const row = el.shadowRoot!.querySelector<HTMLElement>(".obj-row.clickable");
expect(row, "object row is clickable").to.exist;
expect(row!.getAttribute("role")).to.equal("button");
row!.click();
expect(openedEntry, "open-object dispatched with the entry id").to.equal("e1"); // Pool Pump / objA, largest
});
it("searches documents and renders results with the object name", async () => {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/storage": () => SUMMARY,
"maintenance_supporter/documents/search": () => ({
results: [
{ id: "d1", entry_id: "e1", object_name: "Pool Pump", kind: "file", title: "Manual", filename: "m.pdf", size: 100, tags: ["manual"] },
],
}),
},
});
const el = await fixture<MaintenanceStorageSectionCard>(html`
<maintenance-storage-section-card .hass=${hass} .objects=${OBJECTS}></maintenance-storage-section-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
await expand(el);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".doc-search input")!;
input.value = "manual";
input.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 320)); // debounce (250 ms)
await el.updateComplete;
const results = el.shadowRoot!.querySelectorAll(".result-row");
expect(results.length).to.equal(1);
expect(results[0].querySelector(".result-title")!.textContent).to.contain("Manual");
expect(results[0].querySelector(".result-obj")!.textContent).to.contain("Pool Pump");
});
it("falls back to a short id and stays non-clickable when the object is unknown", async () => {
const el = await mount(
{
...SUMMARY, document_count: 1, file_count: 1, link_count: 0,
by_object: { "0123456789abcdef": { bytes: 5, files: 1, links: 0 } },
},
[],
);
await expand(el);
expect(el.shadowRoot!.querySelector(".obj-name")!.textContent!.trim()).to.equal("01234567");
expect(el.shadowRoot!.querySelector(".obj-row.clickable"), "unknown object not clickable").to.not.exist;
});
});
@@ -0,0 +1,211 @@
/** Tests for the extracted task-detail renderers (renderers/task-detail).
*
* The cluster moved out of maintenance-panel.ts as free functions + a
* TaskDetailContext of ~20 panel-owned callbacks. These tests pin the
* behaviour the extraction must preserve:
* - header renders name / object breadcrumb / status chip / actions
* - Complete / Skip route to the panel callbacks with the task
* - operator mode hides archive + the more-menu (read-only surface)
* - the more-menu items (edit/duplicate/reset/snooze/delete) fire callbacks
* - tab bar switches via setActiveTab; history tab renders the timeline
* - KPI bar shows warning days + currency; user badge resolves names
*/
import { expect } from "@open-wc/testing";
import { render } from "lit";
import {
renderTaskDetail,
renderUserBadge,
type TaskDetailContext,
} from "../renderers/task-detail.js";
import type { MaintenanceTask } from "../types";
import { createMockHass } from "./_test-utils.js";
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
return {
id: "t1",
name: "Filter Wechsel",
type: "cleaning",
enabled: true,
status: "due_soon",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
days_until_due: 3,
next_due: "2026-07-10",
last_performed: "2026-06-10",
times_performed: 2,
total_cost: 50,
average_duration: 20,
history: [],
checklist: [],
is_done: false,
archived: false,
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
const { hass } = createMockHass({
handler: () => ({ documents: [] }),
});
return {
lang: "en",
hass: hass as TaskDetailContext["hass"],
entryId: "entry1",
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
activeTab: "overview",
features: {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
},
currencySymbol: "€",
collapsedSections: new Set(),
costDurationToggle: "both",
suggestionDismissed: false,
sparkline: {
lang: "en",
detailStatsData: new Map(),
hasStatsService: false,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
},
history: {
lang: "en",
hass: hass as TaskDetailContext["hass"],
filter: null,
search: "",
currencySymbol: "€",
setFilter: () => undefined,
setSearch: () => undefined,
openEdit: () => undefined,
},
getUserName: () => null,
setActiveTab: () => undefined,
toggleSection: () => undefined,
setCostDurationToggle: () => undefined,
showTaskView: () => undefined,
showObject: () => undefined,
toggleMoreMenu: () => undefined,
closeMoreMenu: () => undefined,
openEdit: () => undefined,
openComplete: () => undefined,
promptSkip: () => undefined,
toggleArchive: () => undefined,
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
applySuggestion: () => undefined,
reanalyze: () => undefined,
dismissSuggestion: () => undefined,
openSeasonalOverrides: () => undefined,
...overrides,
};
}
function mount(t: MaintenanceTask, c: TaskDetailContext): HTMLElement {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderTaskDetail(t, c), host);
return host;
}
describe("task-detail renderer", () => {
afterEach(() => {
document.body.querySelectorAll("div").forEach((el) => {
if (el.parentElement === document.body) el.remove();
});
});
it("renders header with task name, object breadcrumb, and status chip", () => {
const host = mount(task(), ctx());
expect(host.querySelector(".task-name-breadcrumb")!.textContent).to.include("Filter Wechsel");
expect(host.querySelector(".object-name-breadcrumb")!.textContent).to.include("Pool Pump");
const chip = host.querySelector(".status-chip")!;
expect(chip.classList.contains("warning")).to.be.true;
});
it("Complete routes to openComplete with the task; Skip to promptSkip", () => {
let completed: MaintenanceTask | null = null;
let skipped = 0;
const host = mount(task(), ctx({
openComplete: (tk) => { completed = tk; },
promptSkip: () => { skipped++; },
}));
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
(buttons[0] as HTMLElement).click(); // Complete (filled)
(buttons[1] as HTMLElement).click(); // Skip
expect(completed).to.not.be.null;
expect(completed!.name).to.equal("Filter Wechsel");
expect(skipped).to.equal(1);
});
it("operator mode hides archive button and the more-menu", () => {
const host = mount(task(), ctx({ isOperator: true }));
expect(host.querySelector(".more-menu-wrapper")).to.be.null;
// Only Complete / Skip / QR remain.
const labels = [...host.querySelectorAll(".task-header-actions ha-button")]
.map((b) => b.textContent || "");
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
});
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
const calls: string[] = [];
const host = mount(task(), ctx({
moreMenuOpen: true,
closeMoreMenu: () => calls.push("close"),
deleteTask: () => calls.push("delete"),
snoozeTask: () => calls.push("snooze"),
printWorksheet: () => calls.push("worksheet"),
}));
const items = [...host.querySelectorAll(".popup-menu-item")];
expect(items.length).to.equal(6);
(items[3] as HTMLElement).click(); // snooze
(items[4] as HTMLElement).click(); // work sheet (v2.21)
(items[5] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
});
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
let tab = "";
const host = mount(task(), ctx({ setActiveTab: (t2) => { tab = t2; } }));
const tabs = [...host.querySelectorAll(".tab-bar .tab")];
(tabs[1] as HTMLElement).click();
expect(tab).to.equal("history");
const host2 = mount(task({
history: [{ timestamp: "2026-06-10T10:00:00+00:00", type: "completed", notes: "done" }],
}), ctx({ activeTab: "history" }));
expect(host2.querySelector(".history-timeline")).to.not.be.null;
expect(host2.querySelector(".kpi-bar")).to.be.null;
});
it("KPI bar shows warning days and currency symbol", () => {
const host = mount(task(), ctx({ currencySymbol: "$" }));
const kpi = host.querySelector(".kpi-bar")!;
expect(kpi.textContent).to.include("7");
expect(kpi.textContent).to.include("$");
});
it("renderUserBadge resolves the name (and hides when unknown)", () => {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderUserBadge(task({ responsible_user_id: "u1" }) , (id) => (id === "u1" ? "Ingmar" : null)), host);
expect(host.textContent).to.include("Ingmar");
render(renderUserBadge(task({ responsible_user_id: "u2" }), () => null), host);
expect(host.textContent!.trim()).to.equal("");
});
});
@@ -0,0 +1,126 @@
/**
* Lit component tests for the Phase 4 calendar recurrence kinds in
* <maintenance-task-dialog>: weekdays / nth_weekday / day_of_month.
*
* Pins the UI side of the WS contract (backend: test_schedule.py +
* test_ws_roundtrip.py): hydration from the nested `schedule`, the outgoing
* `schedule` payload shape, and that the per-kind field groups render.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog calendar kinds (Phase 4)", () => {
it("hydrates nth_weekday from the nested schedule on openEdit", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Smoke alarm", type: "custom",
schedule_type: "nth_weekday", warning_days: 7, enabled: true,
schedule: { kind: "nth_weekday", nth: 1, weekday: 5 },
} as any);
await el.updateComplete;
expect((el as any)._scheduleType).to.equal("nth_weekday");
expect((el as any)._nth).to.equal("1");
expect((el as any)._nthWeekday).to.equal("5");
});
it("hydrates weekdays from the nested schedule", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Floors", type: "custom",
schedule_type: "weekdays", warning_days: 7, enabled: true,
schedule: { kind: "weekdays", weekdays: [0, 3] },
} as any);
await el.updateComplete;
expect((el as any)._scheduleType).to.equal("weekdays");
expect((el as any)._weekdays).to.deep.equal([0, 3]);
});
it("create sends the nested schedule for nth_weekday", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Smoke alarm";
(el as any)._scheduleType = "nth_weekday";
(el as any)._nth = "1";
(el as any)._nthWeekday = "5";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
expect(msg.schedule).to.deep.equal({ kind: "nth_weekday", nth: 1, weekday: 5 });
});
it("create sends a sorted weekdays schedule", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Floors";
(el as any)._scheduleType = "weekdays";
(el as any)._weekdays = [3, 0];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "weekdays", weekdays: [0, 3] });
});
it("create sends day_of_month", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Rent";
(el as any)._scheduleType = "day_of_month";
(el as any)._domDay = "15";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15 });
});
it("renders 7 weekday chips for the weekdays kind", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "weekdays";
await el.updateComplete;
const chips = el.shadowRoot!.querySelectorAll(".weekday-chip");
expect(chips.length).to.equal(7);
});
it("(#83) create sends last business day with -2 offset", async () => {
const { el, sent } = await mountDialog();
(el as any)._name = "EOM";
(el as any)._scheduleType = "day_of_month";
(el as any)._domLastDay = true;
(el as any)._domBusiness = true;
(el as any)._calOffset = "-2";
await (el as any)._save();
const msg = sent.find((m: any) => m.type === "maintenance_supporter/task/create");
expect(msg.schedule).to.deep.equal({
kind: "day_of_month", day: -1, business: true, offset: -2,
});
});
it("(#83) edit hydrates last-day/business/offset from the stored schedule", async () => {
const { el } = await mountDialog();
(el as any).openEdit("e1", {
id: "t1", name: "EOM", type: "custom", enabled: true,
warning_days: 7, history: [],
schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 },
schedule_type: "day_of_month",
});
await el.updateComplete;
expect((el as any)._domLastDay).to.equal(true);
expect((el as any)._domBusiness).to.equal(true);
expect((el as any)._calOffset).to.equal("-2");
});
});
@@ -0,0 +1,280 @@
/**
* Lit component tests for the v1.3.0 completion-actions sections of
* <maintenance-task-dialog>: on_complete_action + quick_complete_defaults.
*
* The two sections are gated behind `completionActionsEnabled`. Save-payload
* shape is what the WS contract pins on the backend (test_completion_actions.py
* + test_ws_roundtrip.py); these tests pin the UI side: gating, hydration of
* existing values, and outgoing payload shape.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import {
type CreateMockHassResult,
type SentMessage,
type ServiceCall,
createMockHass,
} from "./_test-utils.js";
// v1.3.1: minimal service registry so the schema-driven data form can
// resolve `light.toggle` to a known field set.
const SERVICES_FIXTURE = {
light: {
toggle: {
fields: {
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
transition: { selector: { number: { min: 0, max: 60 } }, required: false },
},
},
turn_on: {
fields: {
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
},
},
},
button: { press: {} }, // no fields → fallback JSON textfield
};
function mockHass(): CreateMockHassResult {
return createMockHass({
services: SERVICES_FIXTURE,
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "newtask123" }),
"maintenance_supporter/task/update": () => ({}),
},
});
}
async function mountDialog(opts: { completionActions?: boolean } = {}): Promise<{
el: MaintenanceTaskDialog;
sent: SentMessage[];
serviceCalls: ServiceCall[];
}> {
const { hass, sent, serviceCalls } = mockHass();
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${opts.completionActions ?? false}
></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent, serviceCalls };
}
function caSections(el: MaintenanceTaskDialog): NodeListOf<HTMLElement> {
return el.shadowRoot!.querySelectorAll<HTMLElement>("details.ca-section");
}
describe("task-dialog completion-actions sections", () => {
it("hides both sections when feature flag is off", async () => {
const { el } = await mountDialog({ completionActions: false });
await el.openCreate("entry_x");
await el.updateComplete;
expect(caSections(el).length, "no .ca-section when gated off").to.equal(0);
});
it("renders both sections when feature flag is on", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
await el.updateComplete;
const sections = caSections(el);
expect(sections.length, "two collapsible sections present").to.equal(2);
expect(sections[0].querySelector("summary")?.textContent || "")
.to.match(/action|aktion/i);
expect(sections[1].querySelector("summary")?.textContent || "")
.to.match(/quick|schnell/i);
});
it("hydrates on_complete_action from an existing task on openEdit", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openEdit("entry_x", {
id: "t1",
name: "Edit hydration",
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
enabled: true,
on_complete_action: {
service: "light.turn_on",
target: { entity_id: "light.workshop" },
data: { brightness: 200 },
},
quick_complete_defaults: {
notes: "Quick note",
cost: 4.5,
duration: 10,
feedback: "needed",
},
} as any);
await el.updateComplete;
// v1.3.1: service field is now an <ha-service-picker> (not a textfield).
const servicePicker = el.shadowRoot!
.querySelector<HTMLElement & { value: string }>(
"details.ca-section ha-service-picker",
);
expect(servicePicker?.value, "service picker hydrated").to.equal("light.turn_on");
// Internal state pins the parsed data dict — ha-form gets it via .data prop.
expect((el as any)._actionData.brightness, "data hydrated").to.equal(200);
// Quick-complete fields are in the second details panel.
// v2.3.x: ha-textfield → ms-textfield to dodge HA's lazy-load (issues #46/#50).
const sections = caSections(el);
const qcInputs = sections[1].querySelectorAll<HTMLElement & { value: string }>(
"ms-textfield",
);
expect(qcInputs[0]?.value, "qc notes").to.equal("Quick note");
expect(qcInputs[1]?.value, "qc cost").to.equal("4.5");
expect(qcInputs[2]?.value, "qc duration").to.equal("10");
const qcSelect = sections[1].querySelector<HTMLSelectElement>("select.qc-feedback");
expect(qcSelect?.value, "qc feedback").to.equal("needed");
});
it("Test button validates without firing the action (no callService side effect)", async () => {
// User feedback: *"setzt Test auch den zustand? dann würde beim
// testen bereits so getan als ob eine Wartung ausgeführt wurde —
// das ist nicht gut"*. The Test button must validate the
// configuration without actually firing the service-call (which
// for input_button.press / counter.increment / etc would have real
// side-effects, e.g. resetting a vacuum's dirty-time sensor).
const { hass, serviceCalls } = createMockHass({ services: SERVICES_FIXTURE });
(hass as any).states = { "light.workshop": { entity_id: "light.workshop", state: "off" } };
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${true}
></maintenance-task-dialog>
`);
await el.openCreate("entry_x");
(el as any)._actionService = "light.toggle";
(el as any)._actionTargetEntity = "light.workshop";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect(serviceCalls.length, "no real service-call dispatched (validate-only)").to.equal(0);
expect((el as any)._actionTestResult).to.equal("ok");
});
it("Test button blocks save + shows error on service/entity domain-mismatch", async () => {
// Issue #50 follow-up: user picked button.press service + input_button.*
// entity. HA's service-call would silently log "Referenced entities ...
// missing or not currently available" — looks like success but never
// fires. Up-front check catches the mismatch.
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "button.press";
(el as any)._actionTargetEntity = "input_button.foo";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
expect((el as any)._actionTestResult).to.equal("error");
const errMsg = (el as any)._actionTestError as string;
expect(errMsg, "error message names both domains")
.to.match(/button\.\*.*input_button\.\*|input_button\.\*.*button\.\*/);
});
it("Test button allows cross-domain services (homeassistant.turn_on, scene.*)", async () => {
const { hass } = createMockHass({
services: { homeassistant: { turn_on: {} }, light: { toggle: {} } },
});
// Override hass.states so the entity-exists check passes
(hass as any).states = { "light.kitchen": { entity_id: "light.kitchen", state: "off" } };
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${true}
></maintenance-task-dialog>
`);
await el.openCreate("entry_x");
(el as any)._actionService = "homeassistant.turn_on";
(el as any)._actionTargetEntity = "light.kitchen";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect((el as any)._actionTestResult).to.equal("ok");
expect((el as any)._actionTestError).to.equal("");
});
it("Test button errors when service is not registered in hass.services", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "imaginary.nope"; // not in SERVICES_FIXTURE
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect((el as any)._actionTestResult).to.equal("error");
expect((el as any)._actionTestError).to.match(/not registered|nicht/i);
});
it("renders ha-form when the picked service has a schema", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "light.toggle";
await el.updateComplete;
const actionSection = caSections(el)[0]!;
// Two ha-form elements expected: the entity picker (always) + the
// data-form (only when service has a schema, like light.toggle does).
expect(
actionSection.querySelector("ha-form.ca-data-form"),
"data ha-form rendered for schemaed service",
).to.exist;
expect(
actionSection.querySelector("ms-textfield"),
"no JSON fallback textfield when schema present",
).to.not.exist;
});
it("falls back to JSON textfield when the service has no schema", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "button.press"; // services.button.press has no fields
await el.updateComplete;
const actionSection = caSections(el)[0]!;
// v2.3.x: there's now an entity-picker ha-form (always present) PLUS
// the optional data-form ha-form (only when service has a schema).
// Disambiguate via the .ca-data-form class — the bare ha-form is the
// entity picker and is always present.
expect(
actionSection.querySelector("ha-form.ca-data-form"),
"no DATA ha-form when service has no fields",
).to.not.exist;
expect(
actionSection.querySelector("ms-textfield"),
"JSON fallback textfield rendered (now ms-textfield)",
).to.exist;
});
it("Test button is disabled when service is empty", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
await el.updateComplete;
const btn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
expect(btn?.disabled, "test button disabled with no service").to.be.true;
});
});
@@ -0,0 +1,97 @@
/**
* <maintenance-task-dialog>: the compound trigger editor (panelconfig-flow
* parity). Hydration from a compound trigger_config into per-condition drafts,
* and the outgoing WS payload building the {type:compound, compound_logic,
* conditions[]} shape the backend expects.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog compound trigger editor (parity)", () => {
it("hydrates compound_logic + conditions from trigger_config", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "AC service", type: "custom",
schedule_type: "sensor_based", warning_days: 7, enabled: true,
trigger_config: {
type: "compound",
compound_logic: "OR",
conditions: [
{ entity_id: "sensor.hours", entity_ids: ["sensor.hours"], type: "runtime", trigger_runtime_hours: 500 },
{ entity_id: "sensor.dust", entity_ids: ["sensor.dust"], type: "threshold", trigger_above: 80 },
],
},
} as any);
await el.updateComplete;
expect((el as any)._triggerType).to.equal("compound");
expect((el as any)._compoundLogic).to.equal("OR");
const conds = (el as any)._compoundConditions;
expect(conds).to.have.length(2);
expect(conds[0].type).to.equal("runtime");
expect(conds[0].runtimeHours).to.equal("500");
expect(conds[0].entityIds).to.equal("sensor.hours");
expect(conds[1].type).to.equal("threshold");
expect(conds[1].above).to.equal("80");
});
it("builds a compound trigger_config on save", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "AC service";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerType = "compound";
(el as any)._compoundLogic = "AND";
(el as any)._compoundConditions = [
{ entityIds: "sensor.hours", type: "runtime", above: "", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "500" },
{ entityIds: "sensor.dust, sensor.dust2", type: "threshold", above: "80", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
const tc = msg.trigger_config;
expect(tc.type).to.equal("compound");
expect(tc.compound_logic).to.equal("AND");
expect(tc.conditions).to.have.length(2);
expect(tc.conditions[0]).to.deep.include({ type: "runtime", trigger_runtime_hours: 500 });
expect(tc.conditions[0].entity_ids).to.deep.equal(["sensor.hours"]);
expect(tc.conditions[1]).to.deep.include({ type: "threshold", trigger_above: 80 });
expect(tc.conditions[1].entity_ids).to.deep.equal(["sensor.dust", "sensor.dust2"]);
});
it("drops conditions with no entity, and clears the trigger if all empty on edit", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "x", type: "custom", schedule_type: "sensor_based",
warning_days: 7, enabled: true,
trigger_config: { type: "compound", compound_logic: "AND", conditions: [] },
} as any);
(el as any)._triggerType = "compound";
(el as any)._compoundConditions = [
{ entityIds: " ", type: "threshold", above: "1", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
expect(msg, "update message sent").to.exist;
expect(msg.trigger_config).to.equal(null);
});
});
@@ -0,0 +1,86 @@
/**
* Lit component test for #42 `interval_days` hydration on edit.
*
* Repro: a sensor_based task whose user has cleared the optional safety
* interval is persisted as `interval_days: null` on the backend. Before
* the fix, opening the edit dialog re-hydrated the field as "30" because
* of the `?.toString() || "30"` fallback in openEdit, silently restoring
* a value the user had explicitly removed.
*
* The test pins both branches of the corrected ternary:
* - null empty string
* - explicit number string of that number
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<MaintenanceTaskDialog> {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return el;
}
describe("task-dialog interval_days hydration (#42 regression)", () => {
it("hydrates a cleared safety interval as empty string, not '30'", async () => {
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Sensor task without safety net",
type: "custom",
schedule_type: "sensor_based",
interval_days: null, // user has cleared the safety interval
warning_days: 7,
enabled: true,
trigger_config: {
type: "threshold",
entity_id: "sensor.x",
trigger_above: 100,
},
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "should be empty when persisted as null").to.equal("");
});
it("hydrates an explicit interval value to its string form", async () => {
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Time-based task with 90-day interval",
type: "custom",
schedule_type: "time_based",
interval_days: 90,
warning_days: 7,
enabled: true,
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "should preserve the persisted number").to.equal("90");
});
it("hydrates a missing interval_days field as empty string", async () => {
// The backend may send the field as undefined when not set on a manual task.
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Manual task",
type: "custom",
schedule_type: "manual",
warning_days: 7,
enabled: true,
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "undefined should hydrate to empty").to.equal("");
});
});
@@ -0,0 +1,87 @@
/**
* <maintenance-task-dialog>: the auto_complete_on_recovery flag (#53)
* hydration from trigger_config, checkbox rendering, and the outgoing
* WS payload (set only when true; absence means off).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog auto-complete-on-recovery (#53)", () => {
it("hydrates the flag from trigger_config on openEdit", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Refill salt", type: "custom",
schedule_type: "sensor_based", warning_days: 7, enabled: true,
trigger_config: {
type: "threshold", entity_id: "sensor.salt",
trigger_below: 20, auto_complete_on_recovery: true,
},
} as any);
await el.updateComplete;
expect((el as any)._autoCompleteOnRecovery).to.be.true;
});
it("sends the flag in trigger_config only when enabled", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Refill salt";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
(el as any)._triggerType = "threshold";
(el as any)._triggerBelow = "20";
(el as any)._autoCompleteOnRecovery = true;
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
expect(msg.trigger_config.auto_complete_on_recovery).to.be.true;
});
it("omits the flag when off (absence means off)", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Refill salt";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
(el as any)._triggerType = "threshold";
(el as any)._triggerBelow = "20";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect("auto_complete_on_recovery" in msg.trigger_config).to.be.false;
});
it("renders the checkbox in the sensor trigger section", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
await el.updateComplete;
const checkboxes = [...el.shadowRoot!.querySelectorAll('input[type="checkbox"]')];
// delta-mode checkbox only shows for counter type; the recovery checkbox
// shows for every sensor trigger type.
expect(checkboxes.length).to.be.greaterThan(0);
const label = [...el.shadowRoot!.querySelectorAll("label")].find((l) =>
/auto-complete|recover/i.test(l.textContent || ""),
);
expect(label, "recovery checkbox label present").to.exist;
});
});
@@ -0,0 +1,125 @@
/** Lit component tests for <maintenance-task-documents>. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-documents.js";
import type { MaintenanceTaskDocuments } from "../components/task-documents";
import { createMockHass } from "./_test-utils.js";
const LINKED = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
const AVAIL = { id: "d2", kind: "file", title: "Invoice", filename: "i.pdf", mime: "application/pdf", size: 50, tags: ["invoice"], task_ids: [] };
async function mount(canWrite = true, docs: unknown[] = [LINKED, AVAIL]) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: docs }),
"maintenance_supporter/documents/update": () => ({ id: "d1" }),
},
});
const el = await fixture<MaintenanceTaskDocuments>(html`
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .taskId=${"t1"} .canWrite=${canWrite}></maintenance-task-documents>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el, sent };
}
describe("task-documents", () => {
it("lists only documents linked to the task", async () => {
const { el } = await mount();
const rows = el.shadowRoot!.querySelectorAll(".tdoc-row");
expect(rows.length).to.equal(1);
expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Manual");
const opts = el.shadowRoot!.querySelectorAll(".tdoc-select option");
expect([...opts].some((o) => o.textContent!.includes("Invoice")), "unlinked doc offered").to.be.true;
});
it("links an available document to the task via WS", async () => {
const { el, sent } = await mount();
const select = el.shadowRoot!.querySelector<HTMLSelectElement>(".tdoc-select")!;
select.value = "d2";
select.dispatchEvent(new Event("change"));
await el.updateComplete;
el.shadowRoot!.querySelector<HTMLButtonElement>(".tdoc-btn")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d2");
expect(msg, "link WS sent").to.exist;
expect(msg!.task_ids).to.deep.equal(["t1"]);
});
it("unlinks a linked document via WS", async () => {
const { el, sent } = await mount();
const unlink = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:link-variant-off"]'),
) as HTMLButtonElement;
unlink.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
expect(msg, "unlink WS sent").to.exist;
expect(msg!.task_ids).to.deep.equal([]);
});
it("hides entirely when the object has no documents", async () => {
const { el } = await mount(true, []);
expect(el.shadowRoot!.querySelector(".task-docs"), "hidden with no docs").to.not.exist;
});
it("sets a per-task jump-to page for a linked PDF via WS", async () => {
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
const { el, sent } = await mount(true, [PDF]);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".tdoc-page");
expect(input, "page field renders for a linked PDF").to.exist;
input!.value = "7";
input!.dispatchEvent(new Event("change"));
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
expect(msg, "page update WS sent").to.exist;
expect(msg!.task_pages).to.deep.equal({ t1: 7 });
});
it("opens a paged PDF at its page via the #page fragment", async () => {
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"], task_pages: { t1: 12 } };
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [PDF] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/d1?authSig=x" }),
},
});
const el = await fixture<MaintenanceTaskDocuments>(html`
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .taskId=${"t1"} .canWrite=${true}></maintenance-task-documents>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const win = { location: { href: "" }, close() {} };
const orig = window.open;
window.open = (() => win as unknown as Window) as typeof window.open;
try {
const eye = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:eye-outline"]'),
) as HTMLButtonElement;
eye.click();
await new Promise((r) => setTimeout(r, 20));
expect(win.location.href).to.contain("#page=12");
} finally {
window.open = orig;
}
});
it("opens a document by clicking its title row (not just the eye icon)", async () => {
const LINK = { id: "d3", kind: "weblink", title: "Online Manual", url: "https://x/m.pdf", tags: [], task_ids: ["t1"] };
const orig = window.open;
let openedUrl = "";
window.open = ((u: string) => { openedUrl = u; return null; }) as typeof window.open;
try {
const { el } = await mount(true, [LINK]);
const info = el.shadowRoot!.querySelector<HTMLElement>(".tdoc-row .tdoc-info")!;
expect(info, "title row present").to.exist;
expect(info.getAttribute("role"), "title row is a button").to.equal("button");
info.click();
await el.updateComplete;
expect(openedUrl, "clicking the title opens the link").to.equal("https://x/m.pdf");
} finally {
window.open = orig;
}
});
});
@@ -0,0 +1,120 @@
/** Tests for <maintenance-trigger-chart> + the shared chart utils. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/trigger-chart.js";
import type { MaintenanceTriggerChart } from "../components/trigger-chart";
import { niceTicks, fmtNum } from "../renderers/chart-utils.js";
const DAY = 86400000;
const NOW = Date.now();
const POINTS = Array.from({ length: 10 }, (_, i) => ({
ts: NOW - (9 - i) * DAY,
val: 50 + i * 3, // 50 … 77
}));
async function mount(props: Partial<MaintenanceTriggerChart> = {}) {
const el = await fixture<MaintenanceTriggerChart>(html`
<maintenance-trigger-chart
.points=${props.points ?? POINTS}
.events=${props.events ?? []}
.unit=${"%"}
.lang=${"en"}
.thresholdBelow=${props.thresholdBelow ?? null}
.thresholdAbove=${props.thresholdAbove ?? null}
.targetValue=${props.targetValue ?? null}
.forceZero=${props.forceZero ?? false}
.rangeDays=${props.rangeDays ?? 30}
></maintenance-trigger-chart>
`);
await new Promise((r) => setTimeout(r, 40)); // let the ResizeObserver fire
await el.updateComplete;
return el;
}
describe("trigger-chart", () => {
it("renders round y-ticks with gridlines at full width", async () => {
const el = await mount();
const svg = el.shadowRoot!.querySelector("svg")!;
expect(svg, "svg rendered").to.exist;
// width follows the host container, not a fixed 300
expect(Number(svg.getAttribute("width"))).to.be.greaterThan(300);
const labels = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
// nice ticks over ~[48,79] land on round steps (50/60/70/80-ish)
expect(labels.some((l) => /^(50|60|70|80)$/.test(l || "")), `round tick in ${labels}`).to.be.true;
});
it("shades the danger zone and recolors in-zone line segments", async () => {
const el = await mount({ thresholdBelow: 60 });
const rects = el.shadowRoot!.querySelectorAll('rect[fill="var(--error-color, #f44336)"]');
expect(rects.length, "zone shading rect").to.be.greaterThan(0);
const redLine = el.shadowRoot!.querySelector('polyline[stroke="var(--error-color, #f44336)"]');
expect(redLine, "in-zone line overlay (clip-path)").to.exist;
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
expect(label).to.contain("60");
});
it("draws a target line for counter progress", async () => {
const el = await mount({ targetValue: 100, forceZero: true });
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
expect(label).to.contain("100");
// forceZero pulls the domain floor to 0
const ticks = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
expect(ticks).to.include("0");
});
it("emits range-change from the range chips", async () => {
const el = await mount({ rangeDays: 30 });
let got = 0;
el.addEventListener("range-change", (e) => { got = (e as CustomEvent).detail.days; });
const chips = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".range-chip:not(.outlier-chip)")];
expect(chips.length).to.equal(4);
chips.find((c) => c.textContent!.trim() === "90d")!.click();
expect(got).to.equal(90);
});
it("emits outlier-toggle from the filter chip", async () => {
const el = await mount({ rangeDays: 30 });
let hide: boolean | null = null;
el.addEventListener("outlier-toggle", (e) => { hide = (e as CustomEvent).detail.hide; });
const chip = el.shadowRoot!.querySelector<HTMLButtonElement>(".outlier-chip");
expect(chip, "outlier chip present").to.exist;
chip!.click();
expect(hide).to.equal(true);
});
it("shows a crosshair value chip on pointer move", async () => {
const el = await mount();
const svg = el.shadowRoot!.querySelector("svg")!;
const r = svg.getBoundingClientRect();
svg.dispatchEvent(new PointerEvent("pointermove", { clientX: r.left + r.width / 2, clientY: r.top + 40, bubbles: true }));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".hover-chip"), "crosshair chip").to.exist;
svg.dispatchEvent(new PointerEvent("pointerleave", { bubbles: true }));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".hover-chip"), "chip clears on leave").to.not.exist;
});
it("renders completion markers in the bottom lane", async () => {
const el = await mount({ events: [{ ts: NOW - 4 * DAY, type: "completed" }] });
const marks = el.shadowRoot!.querySelectorAll('rect[fill="var(--success-color, #4caf50)"]');
expect(marks.length).to.equal(1);
});
});
describe("chart-utils", () => {
it("niceTicks produces round inclusive bounds", () => {
const { ticks, niceMin, niceMax } = niceTicks(53, 77, 4);
expect(niceMin).to.be.at.most(53);
expect(niceMax).to.be.at.least(77);
for (const t of ticks) expect(t % 10 === 0 || t % 5 === 0, `tick ${t} round`).to.be.true;
});
it("fmtNum is compact and consistent", () => {
expect(fmtNum(88000)).to.equal("88k");
expect(fmtNum(1500)).to.equal("1.5k");
expect(fmtNum(-8007.1)).to.equal("-8k");
expect(fmtNum(730)).to.equal("730");
expect(fmtNum(7.53)).to.equal("7.5");
expect(fmtNum(0)).to.equal("0");
});
});
@@ -0,0 +1,106 @@
/** Tests for the trigger-section renderer (progress header + stats-fallback note). */
import { expect } from "@open-wc/testing";
import { render } from "lit";
import { renderTriggerSection, type SparklineContext } from "../renderers/sparkline.js";
import type { MaintenanceTask, StatisticsPoint } from "../types";
function ctx(overrides: Partial<SparklineContext> = {}): SparklineContext {
return {
lang: "en",
detailStatsData: new Map<string, StatisticsPoint[]>(),
hasStatsService: true,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
...overrides,
};
}
function task(overrides: Record<string, unknown>): MaintenanceTask {
return {
id: "t1",
name: "Task",
history: [],
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function mount(t: MaintenanceTask, c: SparklineContext): HTMLElement {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderTriggerSection(t, c), host);
return host;
}
describe("trigger-section", () => {
afterEach(() => {
document.body.querySelectorAll("div").forEach((d) => d.remove());
});
it("shows a progress header for state_change tasks (changes vs target)", () => {
const host = mount(
task({
trigger_config: { type: "state_change", entity_id: "input_boolean.wm", trigger_target_changes: 8 },
trigger_current_value: 5,
}),
ctx(),
);
const main = host.querySelector(".counter-progress-main");
expect(main, "progress header rendered").to.exist;
expect(main!.textContent).to.contain("5");
expect(main!.textContent).to.contain("8");
expect(host.querySelector(".counter-progress-pct")!.textContent).to.contain("63");
});
it("shows a progress header for runtime tasks (hours vs target)", () => {
const host = mount(
task({
trigger_config: { type: "runtime", entity_id: "input_boolean.comp", trigger_runtime_hours: 500 },
trigger_current_value: 400,
}),
ctx(),
);
const pct = host.querySelector(".counter-progress-pct");
expect(pct, "progress header rendered").to.exist;
expect(pct!.textContent).to.contain("80");
expect(pct!.classList.contains("near"), "80% renders in the warning tier").to.be.true;
});
it("notes the statistics fallback when the entity has no long-term stats", () => {
const history = [1, 2, 3].map((i) => ({
timestamp: new Date(Date.now() - i * 86400000).toISOString(),
type: "completed",
trigger_value: i * 10,
}));
const host = mount(
task({
trigger_config: { type: "threshold", entity_id: "sensor.no_stats", trigger_below: 5 },
trigger_current_value: 42,
history,
}),
// Stats were fetched and came back EMPTY → fallback note expected.
ctx({ detailStatsData: new Map([["sensor.no_stats", []]]) }),
);
expect(host.querySelector(".chart-note"), "fallback note shown").to.exist;
});
it("shows no fallback note when real statistics exist", () => {
const stats: StatisticsPoint[] = Array.from({ length: 5 }, (_, i) => ({
ts: Date.now() - (5 - i) * 86400000,
val: 50 + i,
}));
const host = mount(
task({
trigger_config: { type: "threshold", entity_id: "sensor.with_stats", trigger_below: 5 },
trigger_current_value: 55,
}),
ctx({ detailStatsData: new Map([["sensor.with_stats", stats]]) }),
);
expect(host.querySelector(".chart-note"), "no note with real stats").to.not.exist;
expect(host.querySelector("maintenance-trigger-chart"), "chart rendered").to.exist;
});
});
@@ -0,0 +1,107 @@
/**
* Tests for <maintenance-vacation-section-card> (audit gap #10).
*
* An interactive, admin-gated Lovelace card that edits vacation mode from any
* dashboard. Pins:
* - non-admin users get a read-only card (no switch, no date inputs, no save)
* - the enable toggle dispatches vacation/update {enabled}
* - editing dates + Save dispatches vacation/update with start/end/buffer
* - the status pill mirrors the server state (active / scheduled / inactive)
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/vacation-section-card.js";
import type { MaintenanceVacationSectionCard } from "../components/vacation-section-card";
import { createMockHass } from "./_test-utils.js";
const BASE_STATE = {
enabled: false,
is_active: false,
start: "2026-08-01",
end: "2026-08-14",
buffer_days: 7,
exempt_task_ids: [],
};
async function mount(opts: {
isAdmin?: boolean;
state?: Record<string, unknown>;
} = {}) {
const state = { ...BASE_STATE, ...(opts.state ?? {}) };
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/vacation/state": () => state,
"maintenance_supporter/vacation/update": (msg) => ({ ...state, ...msg }),
"maintenance_supporter/vacation/end_now": () => ({ ...state, enabled: false, is_active: false }),
},
});
(hass as Record<string, unknown>).user = { id: "u1", is_admin: opts.isAdmin ?? true };
const el = await fixture<MaintenanceVacationSectionCard>(html`
<maintenance-vacation-section-card .hass=${hass}></maintenance-vacation-section-card>
`);
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
return { el, sent };
}
describe("vacation-section-card", () => {
it("hides every edit control for non-admin users", async () => {
const { el } = await mount({ isAdmin: false });
const root = el.shadowRoot!;
expect(root.querySelector("ha-card"), "card rendered").to.exist;
expect(root.querySelector("ha-switch"), "no enable switch").to.be.null;
expect(root.querySelector('input[type="date"]'), "no date inputs").to.be.null;
expect(root.querySelector(".actions"), "no action buttons").to.be.null;
});
it("admin toggle dispatches vacation/update {enabled: true}", async () => {
const { el, sent } = await mount({ isAdmin: true });
const sw = el.shadowRoot!.querySelector<HTMLInputElement>("ha-switch")!;
expect(sw, "switch rendered for admin").to.exist;
(sw as unknown as { checked: boolean }).checked = true;
sw.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 20));
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
expect(upd.length).to.equal(1);
expect(upd[0].enabled).to.equal(true);
});
it("editing dates + Save dispatches start/end/buffer_days", async () => {
const { el, sent } = await mount({ isAdmin: true });
const root = el.shadowRoot!;
const [start, end] = [...root.querySelectorAll<HTMLInputElement>('input[type="date"]')];
start.value = "2026-09-01";
start.dispatchEvent(new Event("input"));
end.value = "2026-09-10";
end.dispatchEvent(new Event("input"));
const buffer = root.querySelector<HTMLInputElement>('input[type="number"]')!;
buffer.value = "3";
buffer.dispatchEvent(new Event("input"));
await el.updateComplete;
// The dirty state arms the primary Save button.
const save = [...root.querySelectorAll<HTMLButtonElement>(".actions .btn")]
.find((b) => b.classList.contains("primary"))!;
expect(save, "save button armed").to.exist;
save.click();
await new Promise((r) => setTimeout(r, 20));
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
expect(upd.length).to.equal(1);
expect(upd[0].start).to.equal("2026-09-01");
expect(upd[0].end).to.equal("2026-09-10");
expect(upd[0].buffer_days).to.equal(3);
});
it("status pill mirrors the server state", async () => {
const { el } = await mount({ state: { enabled: true, is_active: true } });
const pill = el.shadowRoot!.querySelector(".status-pill")!;
expect(pill.classList.contains("active")).to.be.true;
const { el: el2 } = await mount({ state: { enabled: true, is_active: false } });
expect(el2.shadowRoot!.querySelector(".status-pill")!.classList.contains("scheduled"))
.to.be.true;
});
});
@@ -0,0 +1,90 @@
/** Pure-function tests for the task-table windowing math (helpers/virtual-window).
*
* Pins:
* - the window covers the visible range plus overscan on both sides
* - start/end snap to the step grid (re-render churn control)
* - clamping at the top (scrollTop above the table) and at the bottom
* - spacer heights always add up: padTop + rendered + padBottom == total
* - degenerate inputs (0 rows, table far below viewport) stay sane
*/
import { expect } from "@open-wc/testing";
import { computeWindow, VIRTUAL_MIN_ROWS } from "../helpers/virtual-window.js";
const BASE = {
viewportHeight: 500,
listTop: 200,
rowHeight: 50,
total: 500,
overscan: 12,
step: 6,
};
function invariant(w: { start: number; end: number; padTop: number; padBottom: number }) {
expect(w.start).to.be.at.least(0);
expect(w.end).to.be.at.most(BASE.total);
expect(w.start).to.be.at.most(w.end);
expect(w.padTop).to.equal(w.start * BASE.rowHeight);
expect(w.padBottom).to.equal((BASE.total - w.end) * BASE.rowHeight);
}
describe("virtual-window", () => {
it("at the top the window starts at 0 with no top pad", () => {
const w = computeWindow({ ...BASE, scrollTop: 0 });
expect(w.start).to.equal(0);
expect(w.padTop).to.equal(0);
// 10 visible + 1 + 12 overscan, snapped up to a step multiple.
expect(w.end).to.be.at.least(23);
expect(w.end).to.be.at.most(30);
invariant(w);
});
it("mid-scroll covers the visible range plus overscan", () => {
// scrollTop 1200 → firstVisible = (1200-200)/50 = row 20
const w = computeWindow({ ...BASE, scrollTop: 1200 });
expect(w.start).to.be.at.most(20 - BASE.overscan + BASE.step); // ≤ snapped(8)
expect(w.start).to.be.at.least(0);
expect(w.end).to.be.at.least(20 + 11); // last visible row rendered
expect(w.start % BASE.step).to.equal(0);
invariant(w);
});
it("start/end snap to the step grid", () => {
const a = computeWindow({ ...BASE, scrollTop: 1200 });
const b = computeWindow({ ...BASE, scrollTop: 1200 + 49 }); // < 1 row later
// A sub-row scroll may shift the window at most one step, never per-pixel.
expect(Math.abs(b.start - a.start) % BASE.step).to.equal(0);
});
it("clamps at the bottom: end == total and no bottom pad", () => {
const w = computeWindow({ ...BASE, scrollTop: 200 + 500 * 50 }); // past the end
expect(w.end).to.equal(BASE.total);
expect(w.padBottom).to.equal(0);
invariant(w);
});
it("scrolled above the table (negative firstVisible) starts at 0", () => {
const w = computeWindow({ ...BASE, scrollTop: 0, listTop: 5000 });
expect(w.start).to.equal(0);
expect(w.padTop).to.equal(0);
expect(w.end).to.be.greaterThan(0);
});
it("total 0 → empty window", () => {
const w = computeWindow({ ...BASE, scrollTop: 0, total: 0 });
expect(w).to.deep.equal({ start: 0, end: 0, padTop: 0, padBottom: 0 });
});
it("pads + rendered slice always account for every row", () => {
for (const scrollTop of [0, 137, 999, 5000, 12345, 26000]) {
const w = computeWindow({ ...BASE, scrollTop });
const rendered = w.end - w.start;
expect(w.padTop + rendered * BASE.rowHeight + w.padBottom)
.to.equal(BASE.total * BASE.rowHeight);
}
});
it("exports a sane virtualization threshold", () => {
expect(VIRTUAL_MIN_ROWS).to.be.greaterThan(50);
});
});
@@ -0,0 +1,61 @@
/**
* Warranty status classification (#67) pure + today-injectable.
*
* Pins the 4 states and the 60-day amber threshold so the object-detail chip
* and the objects-table column stay in lockstep with the backend field.
*/
import { expect } from "@open-wc/testing";
import { warrantyStatus, WARRANTY_WARN_DAYS } from "../helpers/warranty";
const TODAY = new Date(2026, 5, 1); // 2026-06-01 local
describe("warrantyStatus (#67)", () => {
it("null / undefined / empty / invalid → none", () => {
expect(warrantyStatus(null, TODAY).kind).to.equal("none");
expect(warrantyStatus(undefined, TODAY).kind).to.equal("none");
expect(warrantyStatus("", TODAY).kind).to.equal("none");
expect(warrantyStatus("not-a-date", TODAY).kind).to.equal("none");
});
it("far future → valid", () => {
const s = warrantyStatus("2027-06-01", TODAY);
expect(s.kind).to.equal("valid");
expect(s.days).to.equal(365);
});
it("61 days out (just past threshold) → valid", () => {
const s = warrantyStatus("2026-08-01", TODAY); // Jun1 -> Aug1 = 61d
expect(s.days).to.equal(61);
expect(s.kind).to.equal("valid");
});
it("exactly the warn threshold (60d) → expiring", () => {
const s = warrantyStatus("2026-07-31", TODAY); // Jun1 -> Jul31 = 60d
expect(s.days).to.equal(WARRANTY_WARN_DAYS);
expect(s.kind).to.equal("expiring");
});
it("within threshold → expiring", () => {
const s = warrantyStatus("2026-06-30", TODAY); // 29d
expect(s.kind).to.equal("expiring");
expect(s.days).to.equal(29);
});
it("today → expiring (0 days)", () => {
const s = warrantyStatus("2026-06-01", TODAY);
expect(s.kind).to.equal("expiring");
expect(s.days).to.equal(0);
});
it("past → expired (negative days)", () => {
const s = warrantyStatus("2026-05-20", TODAY);
expect(s.kind).to.equal("expired");
expect(s.days).to.equal(-12);
});
it("echoes the ISO date back (null when absent)", () => {
expect(warrantyStatus("2027-06-01", TODAY).date).to.equal("2027-06-01");
expect(warrantyStatus("2026-05-20", TODAY).date).to.equal("2026-05-20");
expect(warrantyStatus(null, TODAY).date).to.equal(null);
});
});
@@ -0,0 +1,204 @@
/** Calendar tab / card styles shared between panel-styles.ts and the
* standalone maintenance-supporter-calendar-card.
*
* Extracted from panel-styles.ts in v1.9.0 so the new Lovelace card can
* render identically to the panel's Calendar tab without dragging in the
* full panel CSS.
*/
import { css } from "lit";
export const calendarStyles = css`
.cal-controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
padding: 12px 16px;
border-bottom: 1px solid var(--divider-color);
}
.cal-window-chips {
display: flex;
gap: 4px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
border-radius: 999px;
padding: 3px;
}
.cal-window-chip {
padding: 6px 14px;
border: none;
background: transparent;
color: var(--secondary-text-color);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: 999px;
transition: background 0.12s, color 0.12s;
}
.cal-window-chip:hover { color: var(--primary-text-color); }
.cal-window-chip.active {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
}
/* v2.2.0 past-window chips: visually distinguished from forward chips
so the user grasps the time-direction switch at a glance. Uses a
muted secondary tone instead of the primary blue. v2.3.x: explicit
"N d" / "+N d" prefixes + dot separator so past vs forward groups
read at a glance instead of being two pill rows that look identical
except for a small arrow. (User feedback: *"das 30 und die + sind
noch schlecht angeordnet"*.) */
.cal-past-chips {
/* margin-right replaced by explicit separator below */
}
.cal-past-chip.active {
background: var(--secondary-text-color, #888);
}
.cal-chip-separator {
color: var(--divider-color);
font-size: 8px;
align-self: center;
margin: 0 2px;
line-height: 1;
}
.cal-user-filter {
margin-left: auto;
padding: 6px 10px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.cal-rolling { padding: 8px 16px 32px; }
.cal-day-row {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--divider-color);
}
.cal-day-pill {
width: 56px;
height: 56px;
border-radius: 12px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
border: 1px solid var(--divider-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cal-day-pill.cal-today {
background: var(--primary-color);
border-color: var(--primary-color);
}
.cal-pill-weekday {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--secondary-text-color);
}
.cal-pill-day {
font-size: 20px;
font-weight: 700;
color: var(--primary-text-color);
line-height: 1.1;
}
.cal-day-pill.cal-today .cal-pill-weekday,
.cal-day-pill.cal-today .cal-pill-day {
color: var(--text-primary-color, #fff);
}
.cal-day-content { flex: 1; min-width: 0; }
.cal-day-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.cal-day-month { color: var(--secondary-text-color); font-size: 13px; }
.cal-day-today-badge {
color: var(--primary-color);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cal-empty {
color: var(--secondary-text-color);
font-size: 13px;
font-style: italic;
padding: 4px 0 4px;
}
.cal-event {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.12s;
}
.cal-event:hover { background: var(--state-icon-color, rgba(255,255,255,0.04)); }
.cal-event-projected { opacity: 0.55; }
.cal-event-body { flex: 1; min-width: 0; }
.cal-event-title {
font-size: 14px;
color: var(--primary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-event-recur {
display: block;
font-size: 11px;
color: var(--secondary-text-color);
margin-top: 2px;
}
.cal-event-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
.cal-source-time { color: var(--secondary-text-color); }
.cal-source-sensor { color: var(--primary-color); }
.cal-event-prediction {
display: inline-block;
font-size: 11px;
margin-top: 2px;
padding: 1px 6px;
border-radius: 999px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
border: 1px solid var(--divider-color);
}
.cal-conf-high { color: var(--success-color, #4caf50); border-color: #4caf5044; }
.cal-conf-medium { color: var(--warning-color, #f9a825); border-color: #f9a82544; }
.cal-conf-low { color: var(--error-color, #d32f2f); border-color: #d32f2f44; }
.cal-event-cost {
font-size: 12px;
color: var(--secondary-text-color);
flex-shrink: 0;
}
.cal-status-pill {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #fff;
}
.cal-status-overdue { background: #d32f2f; }
.cal-status-triggered { background: #038fc7; }
.cal-status-due_soon { background: #f9a825; color: #000; }
.cal-status-ok { background: #2e7d32; }
@media (max-width: 600px) {
.cal-controls { padding: 10px 12px; }
.cal-rolling { padding: 6px 12px 24px; }
.cal-day-pill { width: 48px; height: 48px; }
.cal-pill-day { font-size: 17px; }
.cal-user-filter { margin-left: 0; width: 100%; }
}
`;
@@ -0,0 +1,547 @@
/**
* Capture README screenshots for the Maintenance Supporter integration.
*
* Prerequisites:
* docker compose up -d # ha-maint
* docker compose --profile testing up -d # playwright
* python scripts/setup_demo.py && python scripts/seed_history.py
* docker compose restart homeassistant-dev
*
* Usage:
* cd custom_components/maintenance_supporter/frontend-src
* node capture-readme-screenshots.mjs
*
* Or with explicit refresh token:
* HA_REFRESH_TOKEN=<token> node capture-readme-screenshots.mjs
*/
import { chromium } from "playwright";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const HA = "http://homeassistant-dev:8123";
const OUTPUT = path.resolve(__dirname, "../../../docs/images");
// ---------------------------------------------------------------------------
// Access token: env var or auto-extract from docker/.env
// ---------------------------------------------------------------------------
function getAccessToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const envPath = path.resolve(__dirname, "../../../docker/.env");
try {
const lines = fs.readFileSync(envPath, "utf8").split("\n");
for (const line of lines) {
if (line.startsWith("HA_TOKEN=")) return line.split("=")[1].trim();
}
} catch { /* fall through */ }
console.error("Set HA_TOKEN or ensure docker/.env exists with HA_TOKEN=...");
process.exit(1);
}
const ACCESS_TOKEN = getAccessToken();
fs.mkdirSync(OUTPUT, { recursive: true });
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function panelJS() {
return `
var ha = document.querySelector('home-assistant');
var main = ha && ha.shadowRoot && ha.shadowRoot.querySelector('home-assistant-main');
var drawer = main && main.shadowRoot && main.shadowRoot.querySelector('ha-drawer');
var resolver = drawer && drawer.querySelector('partial-panel-resolver');
var custom = resolver && resolver.querySelector('ha-panel-custom');
var p = custom && custom.querySelector('maintenance-supporter-panel');
`;
}
async function login(page) {
await page.goto(HA);
await page.waitForTimeout(1000);
await page.evaluate((token) => {
localStorage.setItem("hassTokens", JSON.stringify({
hassUrl: "http://homeassistant-dev:8123",
clientId: "http://homeassistant-dev:8123/",
refresh_token: "",
access_token: token,
token_type: "Bearer",
expires_in: 86400,
expires: Date.now() + 86400000,
}));
}, ACCESS_TOKEN);
await page.goto(HA + "/maintenance-supporter");
await page.waitForTimeout(8000);
}
/** Returns true if page is still alive, false if it crashed. */
async function setEnglishDark(page) {
try {
await page.evaluate(async () => {
const ha = document.querySelector("home-assistant");
const hass = ha && (ha.__hass || ha.hass);
if (hass && hass.connection) {
await hass.connection.sendMessagePromise({
type: "frontend/set_user_data",
key: "language",
value: { language: "en", number_format: "language" },
});
await hass.connection.sendMessagePromise({
type: "frontend/set_user_data",
key: "core",
value: { selectedTheme: { theme: "default", dark: true } },
});
}
});
await page.goto(HA + "/maintenance-supporter");
await page.waitForTimeout(8000);
return true;
} catch {
console.log(" (language/theme change crashed, will recover)");
return false;
}
}
async function getObjectData(page, maxWait = 30000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
const raw = await page.evaluate(`{
${panelJS()}
var result = [];
if (p && p._objects) {
for (var i = 0; i < p._objects.length; i++) {
var obj = p._objects[i];
var tasks = [];
if (obj.tasks) for (var j = 0; j < obj.tasks.length; j++) {
tasks.push({ id: obj.tasks[j].id, name: obj.tasks[j].name,
checklist: obj.tasks[j].checklist || [],
adaptive: !!(obj.tasks[j].adaptive_config && obj.tasks[j].adaptive_config.enabled) });
}
result.push({ entry_id: obj.entry_id, name: obj.object.name, tasks: tasks });
}
}
JSON.stringify(result);
}`);
const data = JSON.parse(raw);
if (data.length > 0) return data;
await page.waitForTimeout(1000);
}
throw new Error("Timed out waiting for panel objects to load");
}
function find(objects, objName, taskName) {
const obj = objects.find((o) => o.name === objName);
if (!obj) throw new Error(`Object '${objName}' not found`);
if (!taskName) return { entryId: obj.entry_id, obj };
const task = obj.tasks.find((t) => t.name === taskName);
if (!task) throw new Error(`Task '${taskName}' not found in '${objName}'`);
return { entryId: obj.entry_id, taskId: task.id, task, obj };
}
async function shot(page, name) {
const filePath = path.join(OUTPUT, name);
await page.screenshot({ path: filePath });
console.log(` ${name}`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const browser = await chromium.connect("ws://localhost:3000");
console.log("Connected to Playwright server\n");
// ======================== DESKTOP (1280×900) ========================
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: "en", colorScheme: "dark" });
let page = await ctx.newPage();
await login(page);
const langOk = await setEnglishDark(page);
if (!langOk) {
// Language change crashed the page — recreate and re-login
try { await page.close(); } catch { /* already dead */ }
page = await ctx.newPage();
await login(page);
}
const objects = await getObjectData(page);
console.log(`Found ${objects.length} objects: ${objects.map((o) => o.name).join(", ")}\n`);
// 1. Overview
console.log("Desktop screenshots:");
await shot(page, "overview.png");
// 2. Object detail — Electric Car (most tasks)
const ev = find(objects, "Electric Car");
await page.evaluate(`{ ${panelJS()} if (p) p._showObject('${ev.entryId}'); }`);
await page.waitForTimeout(2000);
await shot(page, "object-detail.png");
// 3. Task detail — HVAC Filter Replacement (threshold trigger with sparkline)
const hvac = find(objects, "HVAC System", "Filter Replacement");
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${hvac.entryId}', '${hvac.taskId}'); }`);
await page.waitForTimeout(5000);
await shot(page, "task-detail.png");
// 3b. Multi-entity trigger — Electric Car Tire Pressure Check (4 sensors, threshold < 2.0 bar)
const tire = find(objects, "Electric Car", "Tire Pressure Check");
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${tire.entryId}', '${tire.taskId}'); }`);
await page.waitForTimeout(5000);
await shot(page, "multi-entity-trigger.png");
// 3c. Compound trigger — Water Filter System (OR: threshold + counter)
const wf = find(objects, "Water Filter System", "Cartridge Replacement");
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wf.entryId}', '${wf.taskId}'); }`);
await page.waitForTimeout(5000);
await shot(page, "compound-trigger.png");
// 4. Task history — Washing Machine Drum Cleaning (8 entries)
const wm = find(objects, "Washing Machine", "Drum Cleaning");
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wm.entryId}', '${wm.taskId}'); }`);
await page.waitForTimeout(2000);
// Switch to history tab
await page.evaluate(`{
${panelJS()}
if (p) {
var tabs = p.shadowRoot && p.shadowRoot.querySelectorAll('.tab');
if (tabs && tabs.length > 1) tabs[1].click();
}
}`);
await page.waitForTimeout(1500);
await shot(page, "task-history.png");
// 5. Complete dialog — use a task with checklist if possible
const pool = find(objects, "Swimming Pool", "pH Test");
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${pool.entryId}', '${pool.taskId}'); }`);
await page.waitForTimeout(2000);
await page.evaluate(`{
${panelJS()}
if (p) p._openCompleteDialog('${pool.entryId}', '${pool.taskId}', 'pH Test',
${JSON.stringify(pool.task.checklist)}, ${pool.task.adaptive});
}`);
await page.waitForTimeout(1500);
await shot(page, "complete-dialog.png");
// Close dialog
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// 6. QR dialog
await page.evaluate(`{
${panelJS()}
if (p) p._openQrForTask('${hvac.entryId}', '${hvac.taskId}', 'HVAC System', 'Filter Replacement');
}`);
await page.waitForTimeout(3000);
await shot(page, "qr-dialog.png");
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// 7. Config flow — integration page
await page.goto(HA + "/config/integrations/integration/maintenance_supporter");
await page.waitForTimeout(4000);
await shot(page, "config-flow.png");
// 8. Lovelace card — create temp dashboard via REST then screenshot
try {
// Create dashboard via lovelace WS — must return a promise
const created = await page.evaluate(() => {
return new Promise((resolve, reject) => {
const ha = document.querySelector("home-assistant");
const hass = ha && (ha.__hass || ha.hass);
if (!hass || !hass.connection) { reject(new Error("no hass")); return; }
hass.connection.sendMessagePromise({
type: "lovelace/dashboards/create",
url_path: "maintenance-demo",
title: "Maintenance Demo",
mode: "storage",
}).then(() => resolve(true)).catch((e) => {
// Dashboard may already exist, try saving config directly
resolve(true);
});
});
});
// Save config to the dashboard
await page.evaluate(() => {
return new Promise((resolve) => {
const ha = document.querySelector("home-assistant");
const hass = ha && (ha.__hass || ha.hass);
if (!hass) { resolve(); return; }
hass.connection.sendMessagePromise({
type: "lovelace/config/save",
url_path: "maintenance-demo",
config: {
title: "Maintenance Demo",
views: [{
title: "Overview",
cards: [{
type: "custom:maintenance-supporter-card",
title: "Maintenance Overview",
show_header: true,
show_actions: true,
}],
}],
},
}).then(() => resolve()).catch(() => resolve());
});
});
await page.goto(HA + "/maintenance-demo/0");
await page.waitForTimeout(6000);
await shot(page, "lovelace-card.png");
// Cleanup
await page.evaluate(() => {
const ha = document.querySelector("home-assistant");
const hass = ha && (ha.__hass || ha.hass);
if (hass) {
hass.connection.sendMessagePromise({
type: "lovelace/dashboards/delete",
dashboard_id: "maintenance-demo",
}).catch(() => {});
}
});
} catch (e) {
console.log(" lovelace-card.png SKIPPED:", e.message);
}
// 9. Calendar — show month view with maintenance events
await page.goto(HA + "/calendar");
await page.waitForTimeout(5000);
// Enable the maintenance calendar checkbox
try {
// Pre-select the maintenance calendar in localStorage so the panel shows it
await page.evaluate(() => {
// HA stores selected calendars in hass-panel-calendar-selected per-user
// Try to find and click the checkbox through shadow DOM
const ha = document.querySelector("home-assistant");
const main = ha?.shadowRoot?.querySelector("home-assistant-main");
const drawer = main?.shadowRoot?.querySelector("ha-drawer");
const resolver = drawer?.querySelector("partial-panel-resolver");
const calPanel = resolver?.querySelector("ha-panel-calendar");
const sr = calPanel?.shadowRoot;
if (!sr) return "no-shadow-root";
// Try ha-check-list-item or ha-checkbox or mwc-checkbox
const checkItems = sr.querySelectorAll("ha-check-list-item, ha-checkbox-list-item");
for (const item of checkItems) {
if (item.textContent?.includes("Maintenance")) {
if (!item.selected && !item.checked) item.click();
return "clicked";
}
}
// Try ha-calendar-list-item approach
const calList = sr.querySelector("ha-sidebar-calendars, .calendars");
if (calList) {
const labels = calList.querySelectorAll("label, li, div");
for (const lbl of labels) {
if (lbl.textContent?.includes("Maintenance")) {
const cb = lbl.querySelector("input[type=checkbox], ha-checkbox");
if (cb && !cb.checked) cb.click();
return "clicked-inner";
}
}
}
// Dump what we find for debugging
return "items:" + sr.innerHTML.substring(0, 500);
}).then(r => console.log(" calendar checkbox:", r));
await page.waitForTimeout(3000);
} catch (e) { console.log(" calendar checkbox error:", e.message); }
// Advance the calendar by one month — with a freshly-seeded demo setup most
// tasks' next_due dates are 30-90 days out, so the default "current month"
// view typically renders empty. Attempt a pixel-click on the "Next" chevron
// in HA's calendar toolbar (approx. (650, 85) at 1280×900); fall through
// quietly if the click misses. If the resulting screenshot lands in an
// empty month, prefer keeping the previously committed calendar.png over
// overwriting it — `git checkout HEAD -- docs/images/calendar.png`.
try {
await page.mouse.click(650, 85);
await page.waitForTimeout(2500);
} catch (e) { console.log(" calendar next-month error:", e.message); }
await shot(page, "calendar.png");
// 10. Sensor attributes — Developer Tools States filtered to show one entity with all attributes
await page.goto(HA + "/developer-tools/state");
await page.waitForTimeout(4000);
try {
// Filter entities by typing into the search field
// The filter input is deep in shadow DOM; use evaluate to find and focus it
await page.evaluate(() => {
const ha = document.querySelector("home-assistant");
const main = ha && ha.shadowRoot && ha.shadowRoot.querySelector("home-assistant-main");
const drawer = main && main.shadowRoot && main.shadowRoot.querySelector("ha-drawer");
const resolver = drawer && drawer.querySelector("partial-panel-resolver");
const devTools = resolver && resolver.querySelector("ha-panel-developer-tools");
if (!devTools || !devTools.shadowRoot) return;
const statesPanel = devTools.shadowRoot.querySelector("developer-tools-state");
if (!statesPanel || !statesPanel.shadowRoot) return;
// Find the entity filter input (search field in the table header)
const searchInputs = statesPanel.shadowRoot.querySelectorAll("search-input, ha-search-input, input[type=search]");
// Also try generic inputs
const allInputs = statesPanel.shadowRoot.querySelectorAll("input");
const target = searchInputs[0] || allInputs[0];
if (target) {
target.focus();
target.value = "sensor.hvac_system";
target.dispatchEvent(new Event("input", { bubbles: true }));
target.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await page.waitForTimeout(1000);
// Fallback: use keyboard to type into whatever is focused
await page.keyboard.type("sensor.hvac_system", { delay: 30 });
await page.waitForTimeout(2000);
} catch { /* filter may not work */ }
await shot(page, "entity-attributes.png");
await ctx.close();
// ======================== MOBILE (375×812) ========================
console.log("\nMobile screenshots:");
try {
const mCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en", isMobile: true, colorScheme: "dark" });
const mPage = await mCtx.newPage();
await login(mPage);
// Skip setEnglishDark for mobile — sendMessage can crash mobile contexts
const mObjects = await getObjectData(mPage);
// 11. Mobile overview
await shot(mPage, "mobile-overview.png");
// 12. Mobile task detail
const mHvac = find(mObjects, "HVAC System", "Filter Replacement");
await mPage.evaluate(`{ ${panelJS()} if (p) p._showTask('${mHvac.entryId}', '${mHvac.taskId}'); }`);
await mPage.waitForTimeout(2000);
await shot(mPage, "mobile-task.png");
await mCtx.close();
} catch (e) {
console.log(" mobile screenshots SKIPPED:", e.message);
}
// ======================== NOTIFICATION MOCKUP (375×812) ========================
// Rendered as pure HTML/CSS — no HA login needed, own context for stability
console.log("\nNotification mockup:");
const nCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en" });
const notifPage = await nCtx.newPage();
await notifPage.setContent(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=375, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 375px; height: 812px;
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 30%, #0d2137 70%, #0a1628 100%);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
display: flex; flex-direction: column; align-items: center;
overflow: hidden; position: relative;
}
/* Lock screen clock */
.lock-time {
margin-top: 120px;
font-size: 76px; font-weight: 700; letter-spacing: 1px;
color: #fff;
text-align: center; line-height: 1;
}
.lock-date {
font-size: 20px; font-weight: 400; color: rgba(255,255,255,0.7);
margin-top: 6px; text-align: center;
}
/* Notification card */
.notif-card {
margin-top: 60px; width: 345px;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
border-radius: 16px;
overflow: hidden;
}
.notif-header {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px 0 14px;
}
.notif-icon {
width: 22px; height: 22px; border-radius: 5px;
background: #038fc7;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.notif-icon svg { width: 14px; height: 14px; }
.notif-app {
font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.6);
flex: 1;
}
.notif-time {
font-size: 13px; color: rgba(255,255,255,0.45);
}
.notif-body { padding: 6px 14px 14px 14px; }
.notif-title {
font-size: 15px; font-weight: 600; color: #fff;
margin-bottom: 3px;
}
.notif-message {
font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.85);
line-height: 1.35;
}
/* Action buttons */
.notif-actions {
display: flex;
border-top: 0.5px solid rgba(255,255,255,0.15);
}
.notif-action {
flex: 1;
padding: 13px 0;
text-align: center;
font-size: 15px; font-weight: 500;
color: #64d2ff;
cursor: pointer;
}
.notif-action:not(:last-child) {
border-right: 0.5px solid rgba(255,255,255,0.15);
}
/* Home bar indicator */
.home-bar {
position: absolute; bottom: 8px;
width: 134px; height: 5px;
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
</style>
</head>
<body>
<div class="lock-time">9:41</div>
<div class="lock-date">Saturday, March 7</div>
<div class="notif-card">
<div class="notif-header">
<div class="notif-icon">
<svg viewBox="0 0 24 24" fill="white">
<path d="M12 3L4 9v12h5v-7h6v7h5V9l-8-6z"/>
</svg>
</div>
<span class="notif-app">HOME ASSISTANT</span>
<span class="notif-time">now</span>
</div>
<div class="notif-body">
<div class="notif-title">Maintenance Overdue!</div>
<div class="notif-message">Oil Change for Family Car is 3 day(s) overdue!</div>
</div>
<div class="notif-actions">
<div class="notif-action">\u2705 Complete</div>
<div class="notif-action">\u23ed\ufe0f Skip</div>
<div class="notif-action">\ud83d\udca4 Snooze</div>
</div>
</div>
<div class="home-bar"></div>
</body>
</html>
`, { waitUntil: "networkidle" });
await notifPage.waitForTimeout(500);
await shot(notifPage, "notification-actions.png");
await notifPage.close();
await nCtx.close();
await browser.close();
console.log(`\nAll screenshots saved to ${OUTPUT}`);

Some files were not shown because too many files have changed in this diff Show More