Files
HomeAssistantVS/custom_components/maintenance_supporter/config_flow_helpers.py
T

282 lines
11 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,
)
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
# 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 "",
}