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