"""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 "", }