Files
HomeAssistantVS/custom_components/maintenance_supporter/config_flow_helpers.py
T

358 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_ENDS_COUNT,
CONF_TASK_ENDS_UNTIL,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_SEASON_MONTHS,
SCHEDULE_OFFSET_MAX_DAYS,
)
from .helpers.dates import INTERVAL_UNITS
from .helpers.entity_analyzer import EntityAnalyzer
from .helpers.schedule import (
KIND_CALENDAR,
KIND_DAY_OF_MONTH,
KIND_NTH_WEEKDAY,
KIND_WEEKDAYS,
Schedule,
)
from .helpers.threshold_calculator import ThresholdCalculator, ThresholdSuggestions
_LOGGER = logging.getLogger(__name__)
# Flow error key for a last-performed date after today (strings.json config +
# options ``error``).
ERROR_LAST_PERFORMED_FUTURE = "last_performed_future"
def is_future_date(value: Any) -> bool:
"""True when ``value`` is an ISO date AFTER today (HA's time zone).
A last-performed date is a day the work WAS done: one in the future (the
date picker reaches year 9999) overflowed the schedule math inside every
refresh and kept the object in setup-retry (bug audit 2026-09-27). A
malformed value is not "future" — the selectors already refuse it.
"""
from homeassistant.util import dt as dt_util
from .helpers.dates import parse_iso_date
parsed = parse_iso_date(str(value)) if value else None
return parsed is not None and parsed > dt_util.now().date()
# Calendar recurrence kinds offered in the config + options flows (Phase 4;
# the calendar-entity kind since #187). 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, KIND_CALENDAR)
# #168: every kind that produces a due DATE takes a time of day — the
# interval, the three calendar kinds and a one-off. Sensor-based and
# manual tasks have no due date to refine. Mirrors SCHEDULE_TIME_KINDS in
# the panel's task-dialog.ts.
SCHEDULE_TIME_KINDS = ("time_based", "one_time", *CALENDAR_KIND_VALUES)
_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()
elif kind == KIND_CALENDAR:
# #187: the HA calendar whose events are the occurrences. No default
# when the task has none yet — an empty string would fail the entity
# selector's own validation on submit.
entity_key = vol.Required("entity_id", default=cur["entity_id"]) if cur.get("entity_id") else vol.Required("entity_id")
fields[entity_key] = selector.EntitySelector(selector.EntitySelectorConfig(domain="calendar"))
# (#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=-SCHEDULE_OFFSET_MAX_DAYS, max=SCHEDULE_OFFSET_MAX_DAYS, 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)
if kind == KIND_CALENDAR:
entity_id = user_input.get("entity_id")
if not isinstance(entity_id, str) or not entity_id.startswith("calendar."):
return None
return _with_offset({"kind": KIND_CALENDAR, "entity_id": entity_id})
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,
"entity_id": s.entity_id,
}
def select_default(value: Any, options: list[Any], fallback: Any = "") -> Any:
"""A stored value made safe as a ``SelectSelector`` default.
HA validates the submitted form against the select's options, and a
stored value the options no longer carry — a deleted user, a user
promoted to admin (no longer on the non-admin list), the ``None`` the
panel dialog persisted for "no rotation" — came back as the field's
default and failed that validation on every save: the whole form was
unsaveable (bug audit 2026-09-26). A multi-select keeps only its still
valid members; a single select falls back to ``fallback``.
"""
allowed = {opt["value"] if isinstance(opt, dict) else opt for opt in options}
if isinstance(value, (list, tuple)):
return [v for v in value if v in allowed]
return value if value in allowed else fallback
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 interval_anchor_selector() -> selector.SelectSelector:
"""Shared completion/planned anchor dropdown (issue #30).
Single source for the add-task time-based step (both flows via
ScheduleStepsMixin) and the task-edit form."""
return 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,
)
)
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
# Hardcoded English month labels for the seasonal-window multi-select — same
# "keep the config-flow i18n surface small" choice as the weekday labels above.
_MONTH_LABELS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def season_ends_schema(current_schedule: dict[str, Any] | None) -> dict[Any, Any]:
"""Schema fragment for the seasonal window + finite-series end, prefilled
from the task's nested ``schedule``. Only meaningful for recurring kinds —
callers gate it on schedule_type accordingly."""
cur = current_schedule or {}
season_default = [str(m) for m in (cur.get("season_months") or [])]
ends_raw = cur.get("ends")
ends: dict[str, Any] = ends_raw if isinstance(ends_raw, dict) else {}
count = ends.get("count")
until = ends.get("until")
count_key = (
vol.Optional(CONF_TASK_ENDS_COUNT, default=int(count))
if isinstance(count, int) and count >= 1
else vol.Optional(CONF_TASK_ENDS_COUNT)
)
until_key = (
vol.Optional(CONF_TASK_ENDS_UNTIL, default=str(until))
if isinstance(until, str) and until
else vol.Optional(CONF_TASK_ENDS_UNTIL)
)
return {
vol.Optional(CONF_TASK_SEASON_MONTHS, default=season_default): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[selector.SelectOptionDict(value=str(i + 1), label=_MONTH_LABELS[i]) for i in range(12)],
multiple=True,
mode=selector.SelectSelectorMode.LIST,
)
),
count_key: selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=9999, step=1, mode=selector.NumberSelectorMode.BOX)
),
until_key: selector.DateSelector(),
}
def apply_season_ends(schedule: dict[str, Any], user_input: dict[str, Any]) -> None:
"""Inject the seasonal window + finite-series end from a flow step into a
(recurring) ``schedule`` dict in place — count wins over until; empties clear."""
season = sorted({int(m) for m in (user_input.get(CONF_TASK_SEASON_MONTHS) or []) if str(m).isdigit() and 1 <= int(m) <= 12})
if season:
schedule["season_months"] = season
else:
schedule.pop("season_months", None)
new_ends: dict[str, Any] = {}
count = user_input.get(CONF_TASK_ENDS_COUNT)
until = user_input.get(CONF_TASK_ENDS_UNTIL)
if isinstance(count, (int, float, str)) and str(count).strip() not in ("", "0"):
try:
n = int(float(count)) # NumberSelector may return a float
if n >= 1:
new_ends["count"] = n
except (ValueError, TypeError):
pass
elif until:
new_ends["until"] = str(until)
if new_ends:
schedule["ends"] = new_ends
else:
schedule.pop("ends", None)
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 "",
}